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