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
50#[async_trait]
51impl RestApiClient for GiteaApiClient {
52 fn start_log_group(&self, name: &str) {
53 log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
54 }
55
56 fn end_log_group(&self, _name: &str) {
57 log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
58 }
59
60 fn is_pr_event(&self) -> bool {
61 self.pull_request.is_some()
62 }
63
64 fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError> {
65 self.client = Client::builder()
66 .default_headers(Self::make_headers()?)
67 .user_agent(user_agent)
68 .build()?;
69 Ok(())
70 }
71
72 async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
74 let comments_url = match &self.pull_request {
75 Some(pr_info) => {
76 if pr_info.locked {
77 return Ok(()); }
79 env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
80 self.api_url.join(
81 format!("repos/{}/issues/{}/comments", self.repo, pr_info.number).as_str(),
82 )?
83 }
84 None => {
85 log::info!(
89 "Gitea support for posting thread comments is limited to pull requests only."
90 );
91 return Ok(());
92 }
93 };
94 self.update_comment(comments_url, options).await
95 }
96
97 async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
98 if let Some(pr_info) = self.pull_request.as_ref() {
99 if (!options.allow_draft && pr_info.draft)
101 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
102 || pr_info.locked
103 {
104 return Ok(());
105 }
106
107 let existing_reviews = self
109 .get_existing_review_comments(pr_info.number as i64, &options.marker)
110 .await?;
111
112 if existing_reviews.is_empty() {
113 return Ok(());
114 }
115
116 let mut outdated_comment_ids = Vec::new();
117 let mut outdated_review_ids = Vec::new();
118 let mut reused_comments = std::collections::HashSet::new();
119
120 for review in &existing_reviews {
122 let mut keep_review = false;
123
124 for existing_comment in &review.comments {
125 let mut keep_comment = false;
126
127 for proposed_comment in options.comments.iter() {
129 if Self::match_review_comment(
130 existing_comment,
131 proposed_comment,
132 &options.marker,
133 ) {
134 log::info!(
135 "Using existing review comment: path='{}', line_end={}",
136 existing_comment.path,
137 existing_comment.new_position
138 );
139 reused_comments.insert(proposed_comment.clone());
140 keep_comment = true;
141 keep_review = true;
142 break;
143 }
144 }
145
146 if !keep_comment {
148 outdated_comment_ids.push(existing_comment.id);
149 }
150 }
151
152 if !keep_review {
154 outdated_review_ids.push(review.id);
155 }
156 }
157
158 options.comments.retain(|c| !reused_comments.contains(c));
160
161 if !outdated_comment_ids.is_empty() || !outdated_review_ids.is_empty() {
163 self.delete_outdated_review_comments(
164 pr_info.number as i64,
165 outdated_comment_ids,
166 outdated_review_ids,
167 options.delete_review_comments,
168 )
169 .await?;
170 }
171 }
172 Ok(())
173 }
174
175 async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
176 if let Some(pr_info) = self.pull_request.as_ref() {
177 if (!options.allow_draft && pr_info.draft)
178 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
179 || pr_info.locked
180 {
181 return Ok(());
182 }
183 env::var("GITEA_TOKEN").map_err(|e| ClientError::env_var("GITEA_TOKEN", e))?;
184 let url = self
185 .api_url
186 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
187 let payload = FullReview {
188 event: match options.action {
189 ReviewAction::Comment => String::from("COMMENT"),
190 ReviewAction::Approve => String::from("APPROVED"),
191 ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
192 },
193 body: format!("{}{}", options.marker, options.summary),
194 comments: options
195 .comments
196 .iter()
197 .map(ReviewDiffComment::from)
198 .map(|mut r| {
199 if !r.body.starts_with(&options.marker) {
200 r.body = format!("{}{}", options.marker, r.body);
201 }
202 r
203 })
204 .collect(),
205 commit_id: self.sha.clone(),
206 };
207 let request = self.make_api_request(
208 &self.client,
209 url,
210 Method::POST,
211 Some(
212 serde_json::to_string(&payload)
213 .map_err(|e| ClientError::json("serialize PR review payload", e))?,
214 ),
215 None,
216 )?;
217 let response = self
218 .send_api_request(&self.client, request, &self.rate_limit_headers)
219 .await;
220 match response {
221 Ok(response) => {
222 self.log_response(response, "Failed to post PR review")
223 .await;
224 }
225 Err(e) => {
226 return Err(e.add_request_context("post PR review"));
227 }
228 }
229 }
230 Ok(())
231 }
232
233 fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
234 if vars.is_empty() {
235 return Ok(());
238 }
239 if let Ok(gh_out) = env::var("GITEA_OUTPUT") {
240 return match OpenOptions::new().append(true).open(gh_out) {
241 Ok(mut gh_out_file) => {
242 for out_var in vars {
243 out_var.validate()?;
244 writeln!(&mut gh_out_file, "{}={}\n", out_var.name, out_var.value)
245 .map_err(|e| ClientError::io("write to GITEA_OUTPUT file", e))?;
246 }
247 Ok(())
248 }
249 Err(e) => Err(ClientError::io("write to GITEA_OUTPUT file", e)),
250 };
251 }
252 Ok(())
253 }
254
255 fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
256 if let Ok(gh_out) = env::var("GITEA_STEP_SUMMARY") {
257 return match OpenOptions::new().append(true).open(gh_out) {
259 Ok(mut gh_out_file) => {
260 let result = writeln!(&mut gh_out_file, "\n{comment}\n");
261 result.map_err(|e| ClientError::io("write to GITHUB_STEP_SUMMARY file", e))
262 }
263 Err(e) => Err(ClientError::io("write to GITHUB_STEP_SUMMARY file", e)),
264 };
265 }
266 Ok(())
267 }
268
269 #[cfg(feature = "file-changes")]
270 #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
271 async fn get_list_of_changed_files(
272 &self,
273 file_filter: &FileFilter,
274 lines_changed_only: &LinesChangedOnly,
275 _base_diff: Option<String>,
276 _ignore_index: bool,
277 ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
278 let url_path = match &self.pull_request {
279 Some(pr_info) => format!("repos/{}/pulls/{}.diff", self.repo, pr_info.number),
280 None => format!("repos/{}/commits/{}.diff", self.repo, self.sha),
281 };
282 let url = self.api_url.join(&url_path)?;
283 let mut headers = HeaderMap::new();
284 headers.insert("Accept", HeaderValue::from_str("text/plain")?);
285 let request = self.make_api_request(&self.client, url, Method::GET, None, Some(headers))?;
286 let response = self
287 .send_api_request(&self.client, request, &self.rate_limit_headers)
288 .await?;
289 if let Err(e) = response.error_for_status_ref() {
290 if let Ok(body) = response.text().await {
291 log::error!("Failed to get list of changed files: {e:?}\n{body}");
292 }
293 return Err(ClientError::Request(e).add_request_context("get list of changed files"));
294 }
295 let body = (response.text()).await?.to_string();
296 parse_diff(&body, file_filter, lines_changed_only).map_err(ClientError::DiffError)
297 }
298
299 fn client_kind(&self) -> String {
300 "gitea".to_string()
301 }
302}