git_bot_feedback/client/github/
specific_api.rs

1//! This submodule implements functionality exclusively specific to Github's REST API.
2
3use super::{GithubApiClient, serde_structs::ThreadComment};
4use crate::{
5    CommentKind, CommentPolicy, RestApiClient, RestApiRateLimitHeaders, RestClientError,
6    ThreadCommentOptions,
7    client::{USER_AGENT, send_api_request},
8};
9use reqwest::{Client, Method, Url};
10use std::{collections::HashMap, env, fs};
11
12impl GithubApiClient {
13    /// Instantiate a [`GithubApiClient`] object.
14    pub fn new() -> Result<Self, RestClientError> {
15        let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or(String::from("unknown"));
16        let pull_request = {
17            match event_name.as_str() {
18                "pull_request" => {
19                    // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub.
20                    let event_payload_path = env::var("GITHUB_EVENT_PATH")?;
21                    // event payload JSON file can be overwritten/removed in CI runners
22                    let file_buf = fs::read_to_string(event_payload_path.clone())?;
23                    let payload = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(
24                        &file_buf,
25                    )?;
26                    payload["number"].as_i64().unwrap_or(-1)
27                }
28                _ => -1,
29            }
30        };
31        // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub.
32        let gh_api_url = env::var("GITHUB_API_URL").unwrap_or("https://api.github.com".to_string());
33        let api_url = Url::parse(gh_api_url.as_str())?;
34
35        Ok(GithubApiClient {
36            client: Client::builder()
37                .default_headers(Self::make_headers()?)
38                .user_agent(USER_AGENT)
39                .build()?,
40            pull_request,
41            event_name,
42            api_url,
43            repo: env::var("GITHUB_REPOSITORY")?,
44            sha: env::var("GITHUB_SHA")?,
45            debug_enabled: env::var("ACTIONS_STEP_DEBUG").is_ok_and(|val| &val == "true"),
46            rate_limit_headers: RestApiRateLimitHeaders {
47                reset: "x-ratelimit-reset".to_string(),
48                remaining: "x-ratelimit-remaining".to_string(),
49                retry: "retry-after".to_string(),
50            },
51        })
52    }
53
54    /// Update existing comment or remove old comment(s) and post a new comment
55    pub async fn update_comment(
56        &self,
57        url: Url,
58        options: ThreadCommentOptions,
59    ) -> Result<(), RestClientError> {
60        let is_lgtm = options.kind == CommentKind::Lgtm;
61        let comment_url = self
62            .remove_bot_comments(
63                &url,
64                &options.marker,
65                (options.policy == CommentPolicy::Anew) || (is_lgtm && options.no_lgtm),
66            )
67            .await?;
68        let payload = HashMap::from([("body", options.mark_comment())]);
69
70        if !is_lgtm || !options.no_lgtm {
71            // log::debug!("payload body:\n{:?}", payload);
72            let req_meth = if comment_url.is_some() {
73                Method::PATCH
74            } else {
75                Method::POST
76            };
77            let request = Self::make_api_request(
78                &self.client,
79                comment_url.unwrap_or(url),
80                req_meth,
81                Some(serde_json::json!(&payload).to_string()),
82                None,
83            )?;
84            match send_api_request(&self.client, request, &self.rate_limit_headers).await {
85                Ok(response) => {
86                    Self::log_response(response, "Failed to post thread comment").await;
87                }
88                Err(e) => {
89                    log::error!("Failed to post thread comment: {e:?}");
90                }
91            }
92        }
93        Ok(())
94    }
95
96    /// Remove thread comments previously posted by cpp-linter.
97    async fn remove_bot_comments(
98        &self,
99        url: &Url,
100        comment_marker: &str,
101        delete: bool,
102    ) -> Result<Option<Url>, RestClientError> {
103        let mut comment_url = None;
104        let mut comments_url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
105        let repo = format!(
106            "repos/{}{}/comments",
107            // if we got here, then we know it is on a CI runner as self.repo should be known
108            self.repo,
109            if self.is_pr_event() { "/issues" } else { "" },
110        );
111        let base_comment_url = self.api_url.join(&repo).unwrap();
112        while let Some(ref endpoint) = comments_url {
113            let request =
114                Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?;
115            let result = send_api_request(&self.client, request, &self.rate_limit_headers).await;
116            match result {
117                Err(e) => {
118                    log::error!("Failed to get list of existing thread comments: {e:?}");
119                    return Ok(comment_url);
120                }
121                Ok(response) => {
122                    if !response.status().is_success() {
123                        Self::log_response(
124                            response,
125                            "Failed to get list of existing thread comments",
126                        )
127                        .await;
128                        return Ok(comment_url);
129                    }
130                    comments_url = Self::try_next_page(response.headers());
131                    let payload =
132                        serde_json::from_str::<Vec<ThreadComment>>(&response.text().await?);
133                    match payload {
134                        Err(e) => {
135                            log::error!(
136                                "Failed to deserialize list of existing thread comments: {e}"
137                            );
138                            continue;
139                        }
140                        Ok(payload) => {
141                            for comment in payload {
142                                if comment.body.starts_with(comment_marker) {
143                                    log::debug!(
144                                        "Found bot comment id {} from user {} ({})",
145                                        comment.id,
146                                        comment.user.login,
147                                        comment.user.id,
148                                    );
149                                    let this_comment_url = Url::parse(
150                                        format!("{base_comment_url}/{}", comment.id).as_str(),
151                                    )?;
152                                    if delete || comment_url.is_some() {
153                                        // if not updating: remove all outdated comments
154                                        // if updating: remove all outdated comments except the last one
155
156                                        // use last saved comment_url (if not None) or current comment url
157                                        let del_url = if let Some(last_url) = &comment_url {
158                                            last_url
159                                        } else {
160                                            &this_comment_url
161                                        };
162                                        let req = Self::make_api_request(
163                                            &self.client,
164                                            del_url.as_str(),
165                                            Method::DELETE,
166                                            None,
167                                            None,
168                                        )?;
169                                        match send_api_request(
170                                            &self.client,
171                                            req,
172                                            &self.rate_limit_headers,
173                                        )
174                                        .await
175                                        {
176                                            Ok(result) => {
177                                                if !result.status().is_success() {
178                                                    Self::log_response(
179                                                        result,
180                                                        "Failed to delete old thread comment",
181                                                    )
182                                                    .await;
183                                                }
184                                            }
185                                            Err(e) => {
186                                                log::error!(
187                                                    "Failed to delete old thread comment: {e:?}"
188                                                )
189                                            }
190                                        }
191                                    }
192                                    if !delete {
193                                        comment_url = Some(this_comment_url)
194                                    }
195                                }
196                            }
197                        }
198                    }
199                }
200            }
201        }
202        Ok(comment_url)
203    }
204}