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