Skip to main content

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    AnnotationLevel, CommentKind, CommentPolicy, FileAnnotation, RestApiClient,
6    RestApiRateLimitHeaders, ThreadCommentOptions,
7    client::{ClientError, USER_AGENT, common::PullRequestEventPayload},
8};
9use reqwest::{
10    Client, Method, Url,
11    header::{AUTHORIZATION, HeaderMap, HeaderValue},
12};
13use std::{collections::HashMap, env, fs};
14
15impl GithubApiClient {
16    /// Instantiate a [`GithubApiClient`] object.
17    pub fn new() -> Result<Self, ClientError> {
18        let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or(String::from("unknown"));
19        let pull_request = {
20            match event_name.as_str() {
21                "pull_request" => {
22                    // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub.
23                    let event_payload_path = env::var("GITHUB_EVENT_PATH")
24                        .map_err(|e| ClientError::env_var("GITHUB_EVENT_PATH", e))?;
25                    // event payload JSON file can be overwritten/removed in CI runners
26                    let file_buf = fs::read_to_string(event_payload_path.clone()).map_err(|e| {
27                        ClientError::io(
28                            format!("read event payload from {event_payload_path}").as_str(),
29                            e,
30                        )
31                    })?;
32                    Some(
33                        serde_json::from_str::<PullRequestEventPayload>(&file_buf)
34                            .map_err(|e| ClientError::json("deserialize Event Payload", e))?
35                            .pull_request,
36                    )
37                }
38                _ => None,
39            }
40        };
41        // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub.
42        let gh_api_url = env::var("GITHUB_API_URL").unwrap_or("https://api.github.com".to_string());
43        let api_url = Url::parse(format!("{}/", gh_api_url.trim_end_matches('/')).as_str())?;
44        let graphql_url = Url::parse(
45            env::var("GITHUB_GRAPHQL_URL")
46                .unwrap_or("https://api.github.com/graphql".to_string())
47                .as_str(),
48        )?;
49
50        Ok(Self {
51            client: Client::builder()
52                .default_headers(Self::make_headers()?)
53                .user_agent(USER_AGENT)
54                .build()?,
55            pull_request,
56            event_name,
57            api_url,
58            graphql_url,
59            repo: env::var("GITHUB_REPOSITORY")
60                .map_err(|e| ClientError::env_var("GITHUB_REPOSITORY", e))?,
61            sha: env::var("GITHUB_SHA").map_err(|e| ClientError::env_var("GITHUB_SHA", e))?,
62            debug_enabled: env::var("ACTIONS_STEP_DEBUG").is_ok_and(|val| &val == "true"),
63            rate_limit_headers: RestApiRateLimitHeaders {
64                reset: "x-ratelimit-reset".to_string(),
65                remaining: "x-ratelimit-remaining".to_string(),
66                retry: "retry-after".to_string(),
67            },
68        })
69    }
70
71    pub(super) fn make_headers() -> Result<HeaderMap<HeaderValue>, ClientError> {
72        let mut headers = HeaderMap::new();
73        headers.insert(
74            "Accept",
75            HeaderValue::from_str("application/vnd.github.raw+json")?,
76        );
77        if let Ok(token) = env::var("GITHUB_TOKEN") {
78            log::debug!("Using auth token from GITHUB_TOKEN environment variable");
79            let mut val = HeaderValue::from_str(format!("token {token}").as_str())?;
80            val.set_sensitive(true);
81            headers.insert(AUTHORIZATION, val);
82        } else {
83            log::warn!(
84                "No GITHUB_TOKEN environment variable found! Permission to post comments may be unsatisfied."
85            );
86        }
87        Ok(headers)
88    }
89
90    /// Update existing comment or remove old comment(s) and post a new comment
91    pub async fn update_comment(
92        &self,
93        url: Url,
94        options: ThreadCommentOptions,
95    ) -> Result<(), ClientError> {
96        let is_lgtm = options.kind == CommentKind::Lgtm;
97        let comment_url = self
98            .remove_bot_comments(
99                &url,
100                &options.marker,
101                (options.policy == CommentPolicy::Anew) || (is_lgtm && options.no_lgtm),
102            )
103            .await?;
104        let payload = HashMap::from([("body", options.mark_comment())]);
105
106        if !is_lgtm || !options.no_lgtm {
107            // log::debug!("payload body:\n{:?}", payload);
108            let req_meth = if comment_url.is_some() {
109                Method::PATCH
110            } else {
111                Method::POST
112            };
113            let request = self.make_api_request(
114                &self.client,
115                comment_url.unwrap_or(url),
116                req_meth,
117                Some(serde_json::json!(&payload).to_string()),
118                None,
119            )?;
120            match self
121                .send_api_request(&self.client, request, &self.rate_limit_headers)
122                .await
123            {
124                Ok(response) => {
125                    self.log_response(response, "Failed to post thread comment")
126                        .await;
127                }
128                Err(e) => {
129                    return Err(e.add_request_context("post thread comment"));
130                }
131            }
132        }
133        Ok(())
134    }
135
136    /// Remove thread comments previously posted by cpp-linter.
137    async fn remove_bot_comments(
138        &self,
139        url: &Url,
140        comment_marker: &str,
141        delete: bool,
142    ) -> Result<Option<Url>, ClientError> {
143        let mut comment_url = None;
144        let mut comments_url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
145        let repo = format!(
146            "repos/{}{}/comments",
147            // if we got here, then we know it is on a CI runner as self.repo should be known
148            self.repo,
149            if self.is_pr_event() { "/issues" } else { "" },
150        );
151        let base_comment_url = self.api_url.join(&repo)?;
152        while let Some(endpoint) = comments_url.take() {
153            let request = self.make_api_request(&self.client, endpoint, Method::GET, None, None)?;
154            let result = self
155                .send_api_request(&self.client, request, &self.rate_limit_headers)
156                .await;
157            match result {
158                Err(e) => {
159                    return Err(e.add_request_context("get list of existing thread comments"));
160                }
161                Ok(response) => {
162                    if !response.status().is_success() {
163                        self.log_response(
164                            response,
165                            "Failed to get list of existing thread comments",
166                        )
167                        .await;
168                        return Ok(comment_url);
169                    }
170                    comments_url = self.try_next_page(response.headers());
171                    let payload =
172                        serde_json::from_str::<Vec<ThreadComment>>(&response.text().await?)
173                            .map_err(|e| {
174                                ClientError::json("deserialize list of existing thread comments", e)
175                            })?;
176                    for comment in payload {
177                        if comment.body.starts_with(comment_marker) {
178                            log::debug!(
179                                "Found bot comment id {} from user {} ({})",
180                                comment.id,
181                                comment.user.login,
182                                comment.user.id,
183                            );
184                            let this_comment_url =
185                                Url::parse(format!("{base_comment_url}/{}", comment.id).as_str())?;
186                            if delete || comment_url.is_some() {
187                                // if not updating: remove all outdated comments
188                                // if updating: remove all outdated comments except the last one
189
190                                // use last saved comment_url (if not None) or current comment url
191                                let del_url = if let Some(last_url) = &comment_url {
192                                    last_url
193                                } else {
194                                    &this_comment_url
195                                };
196                                let req = self.make_api_request(
197                                    &self.client,
198                                    del_url.to_owned(),
199                                    Method::DELETE,
200                                    None,
201                                    None,
202                                )?;
203                                let result = self
204                                    .send_api_request(&self.client, req, &self.rate_limit_headers)
205                                    .await
206                                    .map_err(|e| {
207                                        e.add_request_context("delete old thread comment")
208                                    })?;
209                                self.log_response(result, "Failed to delete old thread comment")
210                                    .await;
211                            }
212                            if !delete {
213                                comment_url = Some(this_comment_url)
214                            }
215                        }
216                    }
217                }
218            }
219        }
220        Ok(comment_url)
221    }
222}
223
224impl FileAnnotation {
225    /// Format the [`FileAnnotation`] struct into the specific string format compatible with Github Actions.
226    ///
227    /// See [Github workflow commands documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-a-debug-message).
228    ///
229    /// Example:
230    /// ```text
231    /// ::notice file={name},line={line},col={col},endLine={endLine},endColumn={endColumn},title={title}::{message}
232    /// ```
233    pub fn fmt_github(&self) -> String {
234        let mut annotation_str = format!(
235            "::{}",
236            match self.severity {
237                AnnotationLevel::Debug => "debug",
238                AnnotationLevel::Notice => "notice",
239                AnnotationLevel::Warning => "warning",
240                AnnotationLevel::Error => "error",
241            }
242        );
243        let file_path = self
244            .path
245            .replace("\\", "/")
246            .trim_start()
247            .trim_start_matches('/')
248            .trim_start_matches("./")
249            .trim()
250            .to_string();
251        if !file_path.is_empty() {
252            annotation_str.push_str(" file=");
253            annotation_str.push_str(file_path.as_str());
254            if let Some(start_line) = self.start_line.map(|l| l.max(1)) {
255                annotation_str.push_str(format!(",line={start_line}").as_str());
256                let col = self.start_column.map(|c| c.max(1));
257                if let Some(col) = col {
258                    annotation_str.push_str(format!(",col={col}").as_str());
259                }
260                if let Some(end_line) = self.end_line.map(|l| l.max(1))
261                    && end_line > start_line
262                {
263                    annotation_str.push_str(format!(",endline={end_line}").as_str());
264                    if let Some(end_col) = self.end_column.map(|c| c.max(1)) {
265                        annotation_str.push_str(format!(",endColumn={end_col}").as_str());
266                    }
267                } else if let Some(end_col) = self.end_column.map(|c| c.max(1))
268                    && col.is_none_or(|c| c < end_col)
269                {
270                    annotation_str.push_str(format!(",endColumn={end_col}").as_str());
271                }
272            }
273            if let Some(title) = &self.title {
274                annotation_str.push_str(",title=");
275                annotation_str.push_str(title.as_str());
276            }
277        } else if let Some(title) = &self.title {
278            annotation_str.push_str(" title=");
279            annotation_str.push_str(title.as_str());
280        }
281        format!("{annotation_str}::{}", self.message)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    #![allow(clippy::unwrap_used)]
288
289    use std::env;
290
291    use super::GithubApiClient;
292    use crate::{AnnotationLevel, FileAnnotation};
293
294    #[test]
295    fn generic_message() {
296        let annotation = FileAnnotation {
297            severity: AnnotationLevel::Debug,
298            message: "This is a debug message".to_string(),
299            ..Default::default()
300        };
301        assert_eq!(annotation.fmt_github(), "::debug::This is a debug message");
302    }
303
304    #[test]
305    fn annotate_file() {
306        let annotation = FileAnnotation {
307            severity: AnnotationLevel::Warning,
308            message: "This is a warning message".to_string(),
309            path: "/.\\src\\main.rs".to_string(),
310            title: Some("Warning Title".to_string()),
311            ..Default::default()
312        };
313        assert_eq!(
314            annotation.fmt_github(),
315            "::warning file=src/main.rs,title=Warning Title::This is a warning message"
316        );
317    }
318
319    #[test]
320    fn annotate_file_with_start_line() {
321        let annotation = FileAnnotation {
322            severity: AnnotationLevel::Error,
323            path: "src/lib.rs".to_string(),
324            message: "This is an error message".to_string(),
325            start_line: Some(10),
326            ..Default::default()
327        };
328        assert_eq!(
329            annotation.fmt_github(),
330            "::error file=src/lib.rs,line=10::This is an error message"
331        );
332    }
333
334    #[test]
335    fn annotate_file_with_start_line_col() {
336        let annotation = FileAnnotation {
337            severity: AnnotationLevel::Error,
338            path: "src/lib.rs".to_string(),
339            message: "This is an error message".to_string(),
340            start_line: Some(10),
341            start_column: Some(5),
342            ..Default::default()
343        };
344        assert_eq!(
345            annotation.fmt_github(),
346            "::error file=src/lib.rs,line=10,col=5::This is an error message"
347        );
348    }
349
350    #[test]
351    fn annotate_file_with_line_span() {
352        let annotation = FileAnnotation {
353            severity: AnnotationLevel::Notice,
354            path: "src/lib.rs".to_string(),
355            message: "This is a notice message".to_string(),
356            start_line: Some(10),
357            end_line: Some(20),
358            ..Default::default()
359        };
360        assert_eq!(
361            annotation.fmt_github(),
362            "::notice file=src/lib.rs,line=10,endline=20::This is a notice message"
363        );
364    }
365
366    #[test]
367    fn annotate_file_with_line_col_span() {
368        let annotation = FileAnnotation {
369            severity: AnnotationLevel::Notice,
370            path: "src/lib.rs".to_string(),
371            message: "This is a notice message".to_string(),
372            start_line: Some(10),
373            start_column: Some(5),
374            end_line: Some(20),
375            end_column: Some(15),
376            ..Default::default()
377        };
378        assert_eq!(
379            annotation.fmt_github(),
380            "::notice file=src/lib.rs,line=10,col=5,endline=20,endColumn=15::This is a notice message"
381        );
382    }
383
384    #[test]
385    fn annotate_file_with_col_span_on_1_line() {
386        let annotation = FileAnnotation {
387            severity: AnnotationLevel::Notice,
388            path: "src/lib.rs".to_string(),
389            message: "This is a notice message".to_string(),
390            start_line: Some(10),
391            end_line: Some(2),
392            start_column: Some(5),
393            end_column: Some(15),
394            ..Default::default()
395        };
396        assert_eq!(
397            annotation.fmt_github(),
398            "::notice file=src/lib.rs,line=10,col=5,endColumn=15::This is a notice message"
399        );
400    }
401
402    #[test]
403    fn annotate_blank_path_with_title() {
404        let annotation = FileAnnotation {
405            severity: AnnotationLevel::Debug,
406            message: "This is a debug message".to_string(),
407            title: Some("Debug Title".to_string()),
408            start_line: Some(10),
409            ..Default::default()
410        };
411        assert_eq!(
412            annotation.fmt_github(),
413            "::debug title=Debug Title::This is a debug message"
414        );
415    }
416
417    #[test]
418    fn annotate_blank_path_no_title() {
419        let annotation = FileAnnotation {
420            severity: AnnotationLevel::Debug,
421            message: "This is a debug message".to_string(),
422            start_line: Some(10),
423            ..Default::default()
424        };
425        assert_eq!(annotation.fmt_github(), "::debug::This is a debug message");
426    }
427
428    #[test]
429    fn ensure_api_url_has_trailing_slash() {
430        let gh_api_url = "https://api.github.com/v3";
431        unsafe {
432            env::set_var("GITHUB_REPOSITORY", "2bndy5/git-bot-feedback");
433            env::set_var("GITHUB_SHA", "abc123");
434            env::set_var("GITHUB_EVENT_NAME", "push");
435            // suffix the URL with `//`; expect `client.api_url` to end with a single `/`
436            env::set_var("GITHUB_API_URL", format!("{gh_api_url}//"));
437            env::set_var("GITHUB_GRAPHQL_URL", "https://ghe.example.com/api/graphql");
438        }
439        let client = GithubApiClient::new().unwrap();
440        assert_eq!(
441            client.graphql_url.as_str(),
442            "https://ghe.example.com/api/graphql"
443        );
444        assert!(client.api_url.as_str().ends_with("/v3/"));
445        let endpoint = client.api_url.join("repos/").unwrap();
446        assert_eq!(endpoint.as_str(), "https://api.github.com/v3/repos/");
447    }
448}