git_bot_feedback/client/gitea/
mod.rs1use std::{env, fs::OpenOptions, io::Write, path::PathBuf};
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 workspace_root(&self) -> Result<Option<PathBuf>, ClientError> {
83 env::var("GITEA_WORKSPACE")
84 .or_else(|e| env::var("GITHUB_WORKSPACE").map_err(|_| e))
85 .map(|v| Some(PathBuf::from(v)))
86 .map_err(|e| ClientError::env_var("GITEA_WORKSPACE or GITHUB_WORKSPACE", e))
87 }
88
89 fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError> {
90 self.client = Client::builder()
91 .default_headers(Self::make_headers()?)
92 .user_agent(user_agent)
93 .build()?;
94 Ok(())
95 }
96
97 async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
99 let comments_url = match &self.pull_request {
100 Some(pr_info) => {
101 if pr_info.locked {
102 return Ok(()); }
104 env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
105 self.api_url.join(
106 format!("repos/{}/issues/{}/comments", self.repo, pr_info.number).as_str(),
107 )?
108 }
109 None => {
110 log::info!(
114 "Gitea support for posting thread comments is limited to pull requests only."
115 );
116 return Ok(());
117 }
118 };
119 self.update_comment(comments_url, options).await
120 }
121
122 async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
123 if let Some(pr_info) = self.pull_request.as_ref() {
124 if (!options.allow_draft && pr_info.draft)
126 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
127 || pr_info.locked
128 {
129 return Ok(());
130 }
131
132 let existing_reviews = self
134 .get_existing_review_comments(pr_info.number as i64, &options.marker)
135 .await?;
136
137 if existing_reviews.is_empty() {
138 return Ok(());
139 }
140
141 let mut outdated_comment_ids = Vec::new();
142 let mut outdated_review_ids = Vec::new();
143 let mut reused_comments = std::collections::HashSet::new();
144
145 for review in &existing_reviews {
147 let mut keep_review = false;
148
149 for existing_comment in &review.comments {
150 let mut keep_comment = false;
151
152 for proposed_comment in options.comments.iter() {
154 if Self::match_review_comment(
155 existing_comment,
156 proposed_comment,
157 &options.marker,
158 ) {
159 log::info!(
160 "Using existing review comment: path='{}', line_end={}",
161 existing_comment.path,
162 existing_comment.new_position
163 );
164 reused_comments.insert(proposed_comment.clone());
165 keep_comment = true;
166 keep_review = true;
167 break;
168 }
169 }
170
171 if !keep_comment {
173 outdated_comment_ids.push(existing_comment.id);
174 }
175 }
176
177 if !keep_review {
179 outdated_review_ids.push(review.id);
180 }
181 }
182
183 options.comments.retain(|c| !reused_comments.contains(c));
185
186 if !outdated_comment_ids.is_empty() || !outdated_review_ids.is_empty() {
188 self.delete_outdated_review_comments(
189 pr_info.number as i64,
190 outdated_comment_ids,
191 outdated_review_ids,
192 options.delete_review_comments,
193 )
194 .await?;
195 }
196 }
197 Ok(())
198 }
199
200 async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
201 if let Some(pr_info) = self.pull_request.as_ref() {
202 if (!options.allow_draft && pr_info.draft)
203 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
204 || pr_info.locked
205 {
206 return Ok(());
207 }
208 env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
209 let url = self
210 .api_url
211 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
212 let payload = FullReview {
213 event: match options.action {
214 ReviewAction::Comment => String::from("COMMENT"),
215 ReviewAction::Approve => String::from("APPROVED"),
216 ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
217 },
218 body: if options.summary.starts_with(&options.marker) {
219 options.summary.clone()
220 } else {
221 format!("{}{}", options.marker, options.summary)
222 },
223 comments: options
224 .comments
225 .iter()
226 .map(ReviewDiffComment::from)
227 .map(|mut r| {
228 if !r.body.starts_with(&options.marker) {
229 r.body = format!("{}{}", options.marker, r.body);
230 }
231 r
232 })
233 .collect(),
234 commit_id: self.sha.clone(),
235 };
236 let request = self.make_api_request(
237 &self.client,
238 url,
239 Method::POST,
240 Some(
241 serde_json::to_string(&payload)
242 .map_err(|e| ClientError::json("serialize PR review payload", e))?,
243 ),
244 None,
245 )?;
246 let response = self
247 .send_api_request(&self.client, request, &self.rate_limit_headers)
248 .await;
249 match response {
250 Ok(response) => {
251 self.log_response(response, "Failed to post PR review")
252 .await;
253 }
254 Err(e) => {
255 return Err(e.add_request_context("post PR review"));
256 }
257 }
258 }
259 Ok(())
260 }
261
262 fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
263 if vars.is_empty() {
264 return Ok(());
267 }
268 if let Ok(gh_out) = env::var("GITEA_OUTPUT") {
269 return match OpenOptions::new().append(true).open(gh_out) {
270 Ok(mut gh_out_file) => {
271 for out_var in vars {
272 out_var.validate()?;
273 writeln!(&mut gh_out_file, "{}={}\n", out_var.name, out_var.value)
274 .map_err(|e| ClientError::io("write to GITEA_OUTPUT file", e))?;
275 }
276 Ok(())
277 }
278 Err(e) => Err(ClientError::io("write to GITEA_OUTPUT file", e)),
279 };
280 }
281 Ok(())
282 }
283
284 fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
285 if let Ok(gh_out) = env::var("GITEA_STEP_SUMMARY") {
286 return match OpenOptions::new().append(true).open(gh_out) {
288 Ok(mut gh_out_file) => {
289 writeln!(&mut gh_out_file, "\n{comment}\n")
290 .map_err(|e| ClientError::io("write to GITEA_STEP_SUMMARY file", e))?;
291 if let Some(step_summary_url) = step_summary_url() {
292 log::info!("View step summary at {step_summary_url}");
293 }
294 Ok(())
295 }
296 Err(e) => Err(ClientError::io("open GITEA_STEP_SUMMARY file", e)),
297 };
298 }
299 Ok(())
300 }
301
302 #[cfg(feature = "file-changes")]
303 #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
304 async fn get_list_of_changed_files(
305 &self,
306 file_filter: &FileFilter,
307 lines_changed_only: &LinesChangedOnly,
308 _base_diff: Option<String>,
309 _ignore_index: bool,
310 ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
311 let url_path = match &self.pull_request {
312 Some(pr_info) => format!("repos/{}/pulls/{}.diff", self.repo, pr_info.number),
313 None => format!("repos/{}/commits/{}.diff", self.repo, self.sha),
314 };
315 let url = self.api_url.join(&url_path)?;
316 let mut headers = HeaderMap::new();
317 headers.insert("Accept", HeaderValue::from_str("text/plain")?);
318 let request = self.make_api_request(&self.client, url, Method::GET, None, Some(headers))?;
319 let response = self
320 .send_api_request(&self.client, request, &self.rate_limit_headers)
321 .await?;
322 if let Err(e) = response.error_for_status_ref() {
323 if let Ok(body) = response.text().await {
324 log::error!("Failed to get list of changed files: {e:?}\n{body}");
325 }
326 return Err(ClientError::Request(e).add_request_context("get list of changed files"));
327 }
328 let body = (response.text()).await?.to_string();
329 parse_diff(&body, file_filter, lines_changed_only).map_err(ClientError::DiffError)
330 }
331
332 fn client_kind(&self) -> String {
333 "gitea".to_string()
334 }
335}