git_bot_feedback/client/gitea/
mod.rs1use std::{env, fs::OpenOptions, io::Write};
2
3use async_trait::async_trait;
4use reqwest::{Client, Method};
5use url::Url;
6
7use super::{ClientError, RestApiClient, RestApiRateLimitHeaders, common::PullRequestInfo};
8use crate::{
9 OutputVariable, ReviewAction, ReviewOptions, ThreadCommentOptions,
10 client::common::PullRequestState,
11};
12mod serde_structs;
13use serde_structs::{FullReview, ReviewDiffComment};
14mod specific_api;
15
16#[cfg(feature = "file-changes")]
17use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
18#[cfg(feature = "file-changes")]
19use reqwest::header::{HeaderMap, HeaderValue};
20#[cfg(feature = "file-changes")]
21use std::collections::HashMap;
22
23pub struct GiteaApiClient {
25 client: Client,
27
28 pull_request: Option<PullRequestInfo>,
30
31 pub event_name: String,
33
34 api_url: Url,
36
37 repo: String,
39
40 sha: String,
42
43 pub debug_enabled: bool,
45
46 rate_limit_headers: RestApiRateLimitHeaders,
48}
49
50fn step_summary_url() -> Option<String> {
51 let server_url = env::var("GITEA_SERVER_URL")
52 .or_else(|_| env::var("GITHUB_SERVER_URL"))
53 .ok()?;
54 let repo = env::var("GITEA_REPOSITORY")
55 .or_else(|_| env::var("GITHUB_REPOSITORY"))
56 .ok()?;
57 let run_id = env::var("GITEA_RUN_ID")
58 .or_else(|_| env::var("GITHUB_RUN_ID"))
59 .ok()?;
60 Some(format!(
61 "{}/{repo}/actions/runs/{run_id}",
62 server_url.trim_end_matches('/')
63 ))
64}
65
66#[async_trait]
67impl RestApiClient for GiteaApiClient {
68 fn start_log_group(&self, name: &str) {
69 log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
70 }
71
72 fn end_log_group(&self, _name: &str) {
73 log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
74 }
75
76 fn is_pr_event(&self) -> bool {
77 self.pull_request.is_some()
78 }
79
80 fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError> {
81 self.client = Client::builder()
82 .default_headers(Self::make_headers()?)
83 .user_agent(user_agent)
84 .build()?;
85 Ok(())
86 }
87
88 async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
90 let comments_url = match &self.pull_request {
91 Some(pr_info) => {
92 if pr_info.locked {
93 return Ok(()); }
95 env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
96 self.api_url.join(
97 format!("repos/{}/issues/{}/comments", self.repo, pr_info.number).as_str(),
98 )?
99 }
100 None => {
101 log::info!(
105 "Gitea support for posting thread comments is limited to pull requests only."
106 );
107 return Ok(());
108 }
109 };
110 self.update_comment(comments_url, options).await
111 }
112
113 async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
114 if let Some(pr_info) = self.pull_request.as_ref() {
115 if (!options.allow_draft && pr_info.draft)
117 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
118 || pr_info.locked
119 {
120 return Ok(());
121 }
122
123 let existing_reviews = self
125 .get_existing_review_comments(pr_info.number as i64, &options.marker)
126 .await?;
127
128 if existing_reviews.is_empty() {
129 return Ok(());
130 }
131
132 let mut outdated_comment_ids = Vec::new();
133 let mut outdated_review_ids = Vec::new();
134 let mut reused_comments = std::collections::HashSet::new();
135
136 for review in &existing_reviews {
138 let mut keep_review = false;
139
140 for existing_comment in &review.comments {
141 let mut keep_comment = false;
142
143 for proposed_comment in options.comments.iter() {
145 if Self::match_review_comment(
146 existing_comment,
147 proposed_comment,
148 &options.marker,
149 ) {
150 log::info!(
151 "Using existing review comment: path='{}', line_end={}",
152 existing_comment.path,
153 existing_comment.new_position
154 );
155 reused_comments.insert(proposed_comment.clone());
156 keep_comment = true;
157 keep_review = true;
158 break;
159 }
160 }
161
162 if !keep_comment {
164 outdated_comment_ids.push(existing_comment.id);
165 }
166 }
167
168 if !keep_review {
170 outdated_review_ids.push(review.id);
171 }
172 }
173
174 options.comments.retain(|c| !reused_comments.contains(c));
176
177 if !outdated_comment_ids.is_empty() || !outdated_review_ids.is_empty() {
179 self.delete_outdated_review_comments(
180 pr_info.number as i64,
181 outdated_comment_ids,
182 outdated_review_ids,
183 options.delete_review_comments,
184 )
185 .await?;
186 }
187 }
188 Ok(())
189 }
190
191 async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
192 if let Some(pr_info) = self.pull_request.as_ref() {
193 if (!options.allow_draft && pr_info.draft)
194 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
195 || pr_info.locked
196 {
197 return Ok(());
198 }
199 env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
200 let url = self
201 .api_url
202 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
203 let payload = FullReview {
204 event: match options.action {
205 ReviewAction::Comment => String::from("COMMENT"),
206 ReviewAction::Approve => String::from("APPROVED"),
207 ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
208 },
209 body: if options.summary.starts_with(&options.marker) {
210 options.summary.clone()
211 } else {
212 format!("{}{}", options.marker, options.summary)
213 },
214 comments: options
215 .comments
216 .iter()
217 .map(ReviewDiffComment::from)
218 .map(|mut r| {
219 if !r.body.starts_with(&options.marker) {
220 r.body = format!("{}{}", options.marker, r.body);
221 }
222 r
223 })
224 .collect(),
225 commit_id: self.sha.clone(),
226 };
227 let request = self.make_api_request(
228 &self.client,
229 url,
230 Method::POST,
231 Some(
232 serde_json::to_string(&payload)
233 .map_err(|e| ClientError::json("serialize PR review payload", e))?,
234 ),
235 None,
236 )?;
237 let response = self
238 .send_api_request(&self.client, request, &self.rate_limit_headers)
239 .await;
240 match response {
241 Ok(response) => {
242 self.log_response(response, "Failed to post PR review")
243 .await;
244 }
245 Err(e) => {
246 return Err(e.add_request_context("post PR review"));
247 }
248 }
249 }
250 Ok(())
251 }
252
253 fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
254 if vars.is_empty() {
255 return Ok(());
258 }
259 if let Ok(gh_out) = env::var("GITEA_OUTPUT") {
260 return match OpenOptions::new().append(true).open(gh_out) {
261 Ok(mut gh_out_file) => {
262 for out_var in vars {
263 out_var.validate()?;
264 writeln!(&mut gh_out_file, "{}={}\n", out_var.name, out_var.value)
265 .map_err(|e| ClientError::io("write to GITEA_OUTPUT file", e))?;
266 }
267 Ok(())
268 }
269 Err(e) => Err(ClientError::io("write to GITEA_OUTPUT file", e)),
270 };
271 }
272 Ok(())
273 }
274
275 fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
276 if let Ok(gh_out) = env::var("GITEA_STEP_SUMMARY") {
277 return match OpenOptions::new().append(true).open(gh_out) {
279 Ok(mut gh_out_file) => {
280 writeln!(&mut gh_out_file, "\n{comment}\n")
281 .map_err(|e| ClientError::io("write to GITEA_STEP_SUMMARY file", e))?;
282 if let Some(step_summary_url) = step_summary_url() {
283 log::info!("View step summary at {step_summary_url}");
284 }
285 Ok(())
286 }
287 Err(e) => Err(ClientError::io("open GITEA_STEP_SUMMARY file", e)),
288 };
289 }
290 Ok(())
291 }
292
293 #[cfg(feature = "file-changes")]
294 #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
295 async fn get_list_of_changed_files(
296 &self,
297 file_filter: &FileFilter,
298 lines_changed_only: &LinesChangedOnly,
299 _base_diff: Option<String>,
300 _ignore_index: bool,
301 ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
302 let url_path = match &self.pull_request {
303 Some(pr_info) => format!("repos/{}/pulls/{}.diff", self.repo, pr_info.number),
304 None => format!("repos/{}/commits/{}.diff", self.repo, self.sha),
305 };
306 let url = self.api_url.join(&url_path)?;
307 let mut headers = HeaderMap::new();
308 headers.insert("Accept", HeaderValue::from_str("text/plain")?);
309 let request = self.make_api_request(&self.client, url, Method::GET, None, Some(headers))?;
310 let response = self
311 .send_api_request(&self.client, request, &self.rate_limit_headers)
312 .await?;
313 if let Err(e) = response.error_for_status_ref() {
314 if let Ok(body) = response.text().await {
315 log::error!("Failed to get list of changed files: {e:?}\n{body}");
316 }
317 return Err(ClientError::Request(e).add_request_context("get list of changed files"));
318 }
319 let body = (response.text()).await?.to_string();
320 parse_diff(&body, file_filter, lines_changed_only).map_err(ClientError::DiffError)
321 }
322
323 fn client_kind(&self) -> String {
324 "gitea".to_string()
325 }
326}