Skip to main content

git_bot_feedback/client/gitea/
specific_api.rs

1//! This submodule implements functionality exclusively specific to Github's REST API.
2
3use 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    /// Instantiate a [`GiteaApiClient`] object.
20    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                    // GITEA_*** env vars cannot be overwritten in CI runners on GitHub.
26                    let event_payload_path = env::var("GITEA_EVENT_PATH")
27                        .map_err(|e| ClientError::env_var("GITEA_EVENT_PATH", e))?;
28                    // event payload JSON file can be overwritten/removed in CI runners
29                    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        // GITEA_*** env vars cannot be overwritten in CI runners on GitHub.
40        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    /// Update existing comment or remove old comment(s) and post a new comment
83    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            // log::debug!("payload body:\n{:?}", payload);
100            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    /// Remove thread comments previously posted by cpp-linter.
129    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                                        // if not updating: remove all outdated comments
180                                        // if updating: remove all outdated comments except the last one
181
182                                        // use last saved comment_url (if not None) or current comment url
183                                        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    /// Fetch existing comments for a specific PR review.
233    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    /// Fetch existing reviews for a PR, filtering to only those with body starting with marker.
284    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    /// Check if an existing review comment matches a proposed comment.
345    ///
346    /// Matching is based on: path, line position, and comment body (with marker stripped).
347    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        // Path must match
359        existing.path == proposed.path
360        // Check line position: proposed line_end maps to new_position
361        // If proposed has line_start, it should map to the line range
362        && existing.new_position == proposed.line_end as i64
363        // existing comment body must start with the marker
364        && existing.body.starts_with(marker)
365        // compare comment bodies
366        && existing.body == proposed_body
367    }
368
369    /// Delete outdated review comments and reviews by their IDs.
370    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        // resolve individual comments first
379        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        // Dismiss reviews (mark as outdated)
399        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 // do not dismiss all prior reviews, only this one
411                });
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}