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: format!("{}{}", options.marker, options.summary),
210 comments: options
211 .comments
212 .iter()
213 .map(ReviewDiffComment::from)
214 .map(|mut r| {
215 if !r.body.starts_with(&options.marker) {
216 r.body = format!("{}{}", options.marker, r.body);
217 }
218 r
219 })
220 .collect(),
221 commit_id: self.sha.clone(),
222 };
223 let request = self.make_api_request(
224 &self.client,
225 url,
226 Method::POST,
227 Some(
228 serde_json::to_string(&payload)
229 .map_err(|e| ClientError::json("serialize PR review payload", e))?,
230 ),
231 None,
232 )?;
233 let response = self
234 .send_api_request(&self.client, request, &self.rate_limit_headers)
235 .await;
236 match response {
237 Ok(response) => {
238 self.log_response(response, "Failed to post PR review")
239 .await;
240 }
241 Err(e) => {
242 return Err(e.add_request_context("post PR review"));
243 }
244 }
245 }
246 Ok(())
247 }
248
249 fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
250 if vars.is_empty() {
251 return Ok(());
254 }
255 if let Ok(gh_out) = env::var("GITEA_OUTPUT") {
256 return match OpenOptions::new().append(true).open(gh_out) {
257 Ok(mut gh_out_file) => {
258 for out_var in vars {
259 out_var.validate()?;
260 writeln!(&mut gh_out_file, "{}={}\n", out_var.name, out_var.value)
261 .map_err(|e| ClientError::io("write to GITEA_OUTPUT file", e))?;
262 }
263 Ok(())
264 }
265 Err(e) => Err(ClientError::io("write to GITEA_OUTPUT file", e)),
266 };
267 }
268 Ok(())
269 }
270
271 fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
272 if let Ok(gh_out) = env::var("GITEA_STEP_SUMMARY") {
273 return match OpenOptions::new().append(true).open(gh_out) {
275 Ok(mut gh_out_file) => {
276 writeln!(&mut gh_out_file, "\n{comment}\n")
277 .map_err(|e| ClientError::io("write to GITEA_STEP_SUMMARY file", e))?;
278 if let Some(step_summary_url) = step_summary_url() {
279 log::info!("View step summary at {step_summary_url}");
280 }
281 Ok(())
282 }
283 Err(e) => Err(ClientError::io("open GITEA_STEP_SUMMARY file", e)),
284 };
285 }
286 Ok(())
287 }
288
289 #[cfg(feature = "file-changes")]
290 #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
291 async fn get_list_of_changed_files(
292 &self,
293 file_filter: &FileFilter,
294 lines_changed_only: &LinesChangedOnly,
295 _base_diff: Option<String>,
296 _ignore_index: bool,
297 ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
298 let url_path = match &self.pull_request {
299 Some(pr_info) => format!("repos/{}/pulls/{}.diff", self.repo, pr_info.number),
300 None => format!("repos/{}/commits/{}.diff", self.repo, self.sha),
301 };
302 let url = self.api_url.join(&url_path)?;
303 let mut headers = HeaderMap::new();
304 headers.insert("Accept", HeaderValue::from_str("text/plain")?);
305 let request = self.make_api_request(&self.client, url, Method::GET, None, Some(headers))?;
306 let response = self
307 .send_api_request(&self.client, request, &self.rate_limit_headers)
308 .await?;
309 if let Err(e) = response.error_for_status_ref() {
310 if let Ok(body) = response.text().await {
311 log::error!("Failed to get list of changed files: {e:?}\n{body}");
312 }
313 return Err(ClientError::Request(e).add_request_context("get list of changed files"));
314 }
315 let body = (response.text()).await?.to_string();
316 parse_diff(&body, file_filter, lines_changed_only).map_err(ClientError::DiffError)
317 }
318
319 fn client_kind(&self) -> String {
320 "gitea".to_string()
321 }
322}