1use super::{
4 GiteaApiClient,
5 serde_structs::{GiteaReviewComment, ReviewInfo, ThreadComment},
6};
7use crate::{
8 CommentKind, CommentPolicy, RestApiClient, RestApiRateLimitHeaders, ReviewComment,
9 ThreadCommentOptions,
10 client::{ClientError, USER_AGENT, common::PullRequestEventPayload},
11};
12use reqwest::{
13 Client, Method, Url,
14 header::{AUTHORIZATION, HeaderMap, HeaderValue},
15};
16use std::{collections::HashMap, env, fs};
17
18impl GiteaApiClient {
19 pub fn new() -> Result<Self, ClientError> {
21 let event_name = env::var("GITEA_EVENT_NAME").unwrap_or(String::from("unknown"));
22 let pull_request = {
23 match event_name.as_str() {
24 "pull_request" => {
25 let event_payload_path = env::var("GITEA_EVENT_PATH")
27 .map_err(|e| ClientError::env_var("GITEA_EVENT_PATH", e))?;
28 let file_buf = fs::read_to_string(event_payload_path.clone())
30 .map_err(|e| ClientError::io("read event payload", e))?;
31 let pr_info = serde_json::from_str::<PullRequestEventPayload>(&file_buf)
32 .map_err(|e| ClientError::json("deserialize event payload", e))?
33 .pull_request;
34 Some(pr_info)
35 }
36 _ => None,
37 }
38 };
39 let gh_api_url = format!(
41 "{}/api/v1/",
42 env::var("GITEA_API_URL").map_err(|e| ClientError::env_var("GITEA_API_URL", e))?
43 );
44 let api_url = Url::parse(gh_api_url.as_str())?;
45
46 Ok(Self {
47 client: Client::builder()
48 .default_headers(Self::make_headers()?)
49 .user_agent(USER_AGENT)
50 .build()?,
51 pull_request,
52 event_name,
53 api_url,
54 repo: env::var("GITEA_REPOSITORY")
55 .map_err(|e| ClientError::env_var("GITEA_REPOSITORY", e))?,
56 sha: env::var("GITEA_SHA").map_err(|e| ClientError::env_var("GITEA_SHA", e))?,
57 debug_enabled: env::var("ACTIONS_STEP_DEBUG").is_ok_and(|val| &val == "true"),
58 rate_limit_headers: RestApiRateLimitHeaders {
59 reset: "x-ratelimit-reset".to_string(),
60 remaining: "x-ratelimit-remaining".to_string(),
61 retry: "retry-after".to_string(),
62 },
63 })
64 }
65
66 pub(super) fn make_headers() -> Result<HeaderMap<HeaderValue>, ClientError> {
67 let mut headers = HeaderMap::new();
68 headers.insert("Accept", HeaderValue::from_str("application/json")?);
69 if let Ok(token) = env::var("GITEA_TOKEN") {
70 log::debug!("Using auth token from GITEA_TOKEN environment variable");
71 let mut val = HeaderValue::from_str(format!("token {token}").as_str())?;
72 val.set_sensitive(true);
73 headers.insert(AUTHORIZATION, val);
74 } else {
75 log::warn!(
76 "No GITEA_TOKEN environment variable found! Permission to post comments may be unsatisfied."
77 );
78 }
79 Ok(headers)
80 }
81
82 pub async fn update_comment(
84 &self,
85 url: Url,
86 options: ThreadCommentOptions,
87 ) -> Result<(), ClientError> {
88 let is_lgtm = options.kind == CommentKind::Lgtm;
89 let comment_url = self
90 .remove_bot_comments(
91 &url,
92 &options.marker,
93 (options.policy == CommentPolicy::Anew) || (is_lgtm && options.no_lgtm),
94 )
95 .await?;
96 let payload = HashMap::from([("body", options.mark_comment())]);
97
98 if !is_lgtm || !options.no_lgtm {
99 let req_meth = if comment_url.is_some() {
101 Method::PATCH
102 } else {
103 Method::POST
104 };
105 let request = self.make_api_request(
106 &self.client,
107 comment_url.unwrap_or(url),
108 req_meth,
109 Some(serde_json::json!(&payload).to_string()),
110 None,
111 )?;
112 match self
113 .send_api_request(&self.client, request, &self.rate_limit_headers)
114 .await
115 {
116 Ok(response) => {
117 self.log_response(response, "Failed to post thread comment")
118 .await;
119 }
120 Err(e) => {
121 return Err(e.add_request_context("post thread comment"));
122 }
123 }
124 }
125 Ok(())
126 }
127
128 async fn remove_bot_comments(
130 &self,
131 url: &Url,
132 comment_marker: &str,
133 delete: bool,
134 ) -> Result<Option<Url>, ClientError> {
135 let mut comment_url = None;
136 let mut comments_url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
137 let base_comment_url = format!("{}repos/{}/issues/comments", self.api_url, self.repo);
138 while let Some(endpoint) = comments_url.take() {
139 let request = self.make_api_request(&self.client, endpoint, Method::GET, None, None)?;
140 let result = self
141 .send_api_request(&self.client, request, &self.rate_limit_headers)
142 .await;
143 match result {
144 Err(e) => {
145 return Err(e.add_request_context("get list of existing thread comments"));
146 }
147 Ok(response) => {
148 if !response.status().is_success() {
149 self.log_response(
150 response,
151 "Failed to get list of existing thread comments",
152 )
153 .await;
154 return Ok(comment_url);
155 }
156 comments_url = self.try_next_page(response.headers());
157 let payload =
158 serde_json::from_str::<Vec<ThreadComment>>(&response.text().await?);
159 match payload {
160 Err(e) => {
161 return Err(ClientError::json(
162 "deserialize list of existing thread comments",
163 e,
164 ));
165 }
166 Ok(payload) => {
167 for comment in payload {
168 if comment.body.starts_with(comment_marker) {
169 log::debug!(
170 "Found bot comment id {} from user {} ({})",
171 comment.id,
172 comment.user.login,
173 comment.user.id,
174 );
175 let this_comment_url = Url::parse(
176 format!("{base_comment_url}/{}", comment.id).as_str(),
177 )?;
178 if delete || comment_url.is_some() {
179 let del_url = if let Some(last_url) = &comment_url {
184 last_url
185 } else {
186 &this_comment_url
187 };
188 let req = self.make_api_request(
189 &self.client,
190 del_url.clone(),
191 Method::DELETE,
192 None,
193 None,
194 )?;
195 match self
196 .send_api_request(
197 &self.client,
198 req,
199 &self.rate_limit_headers,
200 )
201 .await
202 {
203 Ok(result) => {
204 if !result.status().is_success() {
205 self.log_response(
206 result,
207 "Failed to delete old thread comment",
208 )
209 .await;
210 }
211 }
212 Err(e) => {
213 return Err(e.add_request_context(
214 "delete old thread comment",
215 ));
216 }
217 }
218 }
219 if !delete {
220 comment_url = Some(this_comment_url)
221 }
222 }
223 }
224 }
225 }
226 }
227 }
228 }
229 Ok(comment_url)
230 }
231
232 async fn get_review_comments(
234 &self,
235 review_id: i64,
236 pr_number: i64,
237 marker: &str,
238 ) -> Result<Vec<GiteaReviewComment>, ClientError> {
239 let mut review_comments = vec![];
240 let mut comments_url = Some(Url::parse(
241 format!(
242 "{}repos/{}/pulls/{pr_number}/reviews/{review_id}/comments",
243 self.api_url, self.repo
244 )
245 .as_str(),
246 )?);
247 while let Some(url) = comments_url.take() {
248 let request = self.make_api_request(&self.client, url, Method::GET, None, None)?;
249 let result = self
250 .send_api_request(&self.client, request, &self.rate_limit_headers)
251 .await;
252 match result {
253 Err(e) => {
254 return Err(e.add_request_context("get comments for a review"));
255 }
256 Ok(response) => {
257 if !response.status().is_success() {
258 self.log_response(response, "Failed to get comments for a review")
259 .await;
260 continue;
261 }
262 comments_url = self.try_next_page(response.headers());
263 let comments_payload =
264 serde_json::from_str::<Vec<GiteaReviewComment>>(&response.text().await?);
265 match comments_payload {
266 Err(e) => {
267 return Err(ClientError::json("deserialize comments for a review", e));
268 }
269 Ok(comments) => {
270 for comment in comments {
271 if comment.body.starts_with(marker) {
272 review_comments.push(comment);
273 }
274 }
275 }
276 }
277 }
278 }
279 }
280 Ok(review_comments)
281 }
282
283 pub(super) async fn get_existing_review_comments(
285 &self,
286 pr_number: i64,
287 marker: &str,
288 ) -> Result<Vec<ReviewInfo>, ClientError> {
289 let mut reviews = Vec::new();
290 let mut reviews_url = Some(Url::parse_with_params(
291 format!(
292 "{}repos/{}/pulls/{pr_number}/reviews",
293 self.api_url, self.repo,
294 )
295 .as_str(),
296 &[("page", "1")],
297 )?);
298
299 while let Some(endpoint) = reviews_url.take() {
300 let request = self.make_api_request(&self.client, endpoint, Method::GET, None, None)?;
301 let result = self
302 .send_api_request(&self.client, request, &self.rate_limit_headers)
303 .await;
304 match result {
305 Err(e) => {
306 return Err(e.add_request_context("get existing PR reviews"));
307 }
308 Ok(response) => {
309 if !response.status().is_success() {
310 self.log_response(response, "Failed to get existing PR reviews")
311 .await;
312 break;
313 }
314 reviews_url = self.try_next_page(response.headers());
315 let payload = serde_json::from_str::<Vec<ReviewInfo>>(&response.text().await?);
316 match payload {
317 Err(e) => {
318 return Err(ClientError::json("deserialize existing PR reviews", e));
319 }
320 Ok(payload) => {
321 for mut review in payload {
322 if review.body.starts_with(marker) {
323 log::debug!(
324 "Found bot review id {} with {} comments",
325 review.id,
326 review.comments_count
327 );
328 if review.comments_count > 0 {
329 review.comments = self
330 .get_review_comments(review.id, pr_number, marker)
331 .await?;
332 }
333 reviews.push(review);
334 }
335 }
336 }
337 }
338 }
339 }
340 }
341 Ok(reviews)
342 }
343
344 pub(super) fn match_review_comment(
348 existing: &GiteaReviewComment,
349 proposed: &ReviewComment,
350 marker: &str,
351 ) -> bool {
352 let proposed_body = if !proposed.comment.starts_with(marker) {
353 format!("{marker}{}", proposed.comment)
354 } else {
355 proposed.comment.clone()
356 };
357
358 existing.path == proposed.path
360 && existing.new_position == proposed.line_end as i64
363 && existing.body.starts_with(marker)
365 && existing.body == proposed_body
367 }
368
369 pub(super) async fn delete_outdated_review_comments(
371 &self,
372 pr_number: i64,
373 comment_ids: Vec<i64>,
374 review_ids: Vec<i64>,
375 delete: bool,
376 ) -> Result<(), ClientError> {
377 let base_url = format!("{}repos/{}/pulls/{pr_number}", self.api_url, self.repo);
378 for comment_id in comment_ids {
380 let comment_url =
381 Url::parse(format!("{base_url}/comments/{comment_id}/resolve").as_str())?;
382 let request =
383 self.make_api_request(&self.client, comment_url, Method::POST, None, None)?;
384 match self
385 .send_api_request(&self.client, request, &self.rate_limit_headers)
386 .await
387 {
388 Ok(result) => {
389 self.log_response(result, "Failed to resolve outdated review comment")
390 .await;
391 }
392 Err(e) => {
393 return Err(e.add_request_context("resolve outdated review comment"));
394 }
395 }
396 }
397
398 for review_id in review_ids {
400 let (request, log_prompt) = if delete {
401 let url = Url::parse(format!("{base_url}/reviews/{review_id}").as_str())?;
402 let request =
403 self.make_api_request(&self.client, url, Method::DELETE, None, None)?;
404 (request, "Failed to delete outdated review")
405 } else {
406 let url =
407 Url::parse(format!("{base_url}/reviews/{review_id}/dismissals").as_str())?;
408 let body = serde_json::json!({
409 "message": "outdated review",
410 "priors": false });
412 let request = self.make_api_request(
413 &self.client,
414 url,
415 Method::POST,
416 Some(body.to_string()),
417 None,
418 )?;
419 (request, "Failed to dismiss outdated review")
420 };
421
422 match self
423 .send_api_request(&self.client, request, &self.rate_limit_headers)
424 .await
425 {
426 Ok(result) => {
427 self.log_response(result, log_prompt).await;
428 }
429 Err(e) => {
430 return Err(e.add_request_context(log_prompt));
431 }
432 }
433 }
434
435 Ok(())
436 }
437}