cpp_linter/rest_api/github/
mod.rs

1//! This module holds functionality specific to using Github's REST API.
2//!
3//! In the root module, we just implement the RestApiClient trait.
4//! In other (private) submodules we implement behavior specific to Github's REST API.
5
6use std::env;
7use std::fs::OpenOptions;
8use std::io::Write;
9use std::sync::{Arc, Mutex};
10
11// non-std crates
12use anyhow::{Context, Result};
13use reqwest::{
14    header::{HeaderMap, HeaderValue, AUTHORIZATION},
15    Client, Method, Url,
16};
17use serde_json;
18
19// project specific modules/crates
20use super::{RestApiClient, RestApiRateLimitHeaders};
21use crate::clang_tools::clang_format::tally_format_advice;
22use crate::clang_tools::clang_tidy::tally_tidy_advice;
23use crate::clang_tools::ClangVersions;
24use crate::cli::{FeedbackInput, ThreadComments};
25use crate::common_fs::{FileFilter, FileObj};
26use crate::git::{get_diff, open_repo, parse_diff, parse_diff_from_buf};
27
28// private submodules.
29mod serde_structs;
30mod specific_api;
31use serde_structs::{GithubChangedFile, PushEventFiles};
32
33/// A structure to work with Github REST API.
34pub struct GithubApiClient {
35    /// The HTTP request client to be used for all REST API calls.
36    client: Client,
37
38    /// The CI run's event payload from the webhook that triggered the workflow.
39    pull_request: i64,
40
41    /// The name of the event that was triggered when running cpp_linter.
42    pub event_name: String,
43
44    /// The value of the `GITHUB_API_URL` environment variable.
45    api_url: Url,
46
47    /// The value of the `GITHUB_REPOSITORY` environment variable.
48    repo: Option<String>,
49
50    /// The value of the `GITHUB_SHA` environment variable.
51    sha: Option<String>,
52
53    /// The value of the `ACTIONS_STEP_DEBUG` environment variable.
54    pub debug_enabled: bool,
55
56    /// The response header names that describe the rate limit status.
57    rate_limit_headers: RestApiRateLimitHeaders,
58}
59
60// implement the RestApiClient trait for the GithubApiClient
61impl RestApiClient for GithubApiClient {
62    fn set_exit_code(
63        &self,
64        checks_failed: u64,
65        format_checks_failed: Option<u64>,
66        tidy_checks_failed: Option<u64>,
67    ) -> u64 {
68        if let Ok(gh_out) = env::var("GITHUB_OUTPUT") {
69            if let Ok(mut gh_out_file) = OpenOptions::new().append(true).open(gh_out) {
70                for (prompt, value) in [
71                    ("checks-failed", Some(checks_failed)),
72                    ("format-checks-failed", format_checks_failed),
73                    ("tidy-checks-failed", tidy_checks_failed),
74                ] {
75                    if let Err(e) = writeln!(gh_out_file, "{prompt}={}", value.unwrap_or(0),) {
76                        log::error!("Could not write to GITHUB_OUTPUT file: {}", e);
77                        break;
78                    }
79                }
80                if let Err(e) = gh_out_file.flush() {
81                    log::debug!("Failed to flush buffer to GITHUB_OUTPUT file: {e:?}");
82                }
83            } else {
84                log::debug!("GITHUB_OUTPUT file could not be opened");
85            }
86        }
87        log::info!(
88            "{} clang-format-checks-failed",
89            format_checks_failed.unwrap_or(0)
90        );
91        log::info!(
92            "{} clang-tidy-checks-failed",
93            tidy_checks_failed.unwrap_or(0)
94        );
95        log::info!("{checks_failed} checks-failed");
96        checks_failed
97    }
98
99    /// This prints a line to indicate the beginning of a related group of log statements.
100    fn start_log_group(&self, name: String) {
101        log::info!(target: "CI_LOG_GROUPING", "::group::{}", name);
102    }
103
104    /// This prints a line to indicate the ending of a related group of log statements.
105    fn end_log_group(&self) {
106        log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
107    }
108
109    fn make_headers() -> Result<HeaderMap<HeaderValue>> {
110        let mut headers = HeaderMap::new();
111        headers.insert(
112            "Accept",
113            HeaderValue::from_str("application/vnd.github.raw+json")?,
114        );
115        if let Ok(token) = env::var("GITHUB_TOKEN") {
116            log::debug!("Using auth token from GITHUB_TOKEN environment variable");
117            let mut val = HeaderValue::from_str(format!("token {token}").as_str())?;
118            val.set_sensitive(true);
119            headers.insert(AUTHORIZATION, val);
120        }
121        Ok(headers)
122    }
123
124    async fn get_list_of_changed_files(&self, file_filter: &FileFilter) -> Result<Vec<FileObj>> {
125        if env::var("CI").is_ok_and(|val| val.as_str() == "true")
126            && self.repo.is_some()
127            && self.sha.is_some()
128        {
129            // get diff from Github REST API
130            let is_pr = self.event_name == "pull_request";
131            let pr = self.pull_request.to_string();
132            let sha = self.sha.clone().unwrap();
133            let url = self
134                .api_url
135                .join("repos/")?
136                .join(format!("{}/", self.repo.as_ref().unwrap()).as_str())?
137                .join(if is_pr { "pulls/" } else { "commits/" })?
138                .join(if is_pr { pr.as_str() } else { sha.as_str() })?;
139            let mut diff_header = HeaderMap::new();
140            diff_header.insert("Accept", "application/vnd.github.diff".parse()?);
141            log::debug!("Getting file changes from {}", url.as_str());
142            let request = Self::make_api_request(
143                &self.client,
144                url.as_str(),
145                Method::GET,
146                None,
147                Some(diff_header),
148            )?;
149            let response = Self::send_api_request(
150                self.client.clone(),
151                request,
152                self.rate_limit_headers.to_owned(),
153                0,
154            )
155            .await
156            .with_context(|| "Failed to get list of changed files from GitHub server.")?;
157            if response.status().is_success() {
158                Ok(parse_diff_from_buf(&response.bytes().await?, file_filter))
159            } else {
160                let endpoint = if is_pr {
161                    Url::parse(format!("{}/files", url.as_str()).as_str())?
162                } else {
163                    url
164                };
165                Self::log_response(response, "Failed to get full diff for event").await;
166                log::debug!("Trying paginated request to {}", endpoint.as_str());
167                self.get_changed_files_paginated(endpoint, file_filter)
168                    .await
169            }
170        } else {
171            // get diff from libgit2 API
172            let repo = open_repo(".").with_context(|| {
173                "Please ensure the repository is checked out before running cpp-linter."
174            })?;
175            let list = parse_diff(&get_diff(&repo)?, file_filter);
176            Ok(list)
177        }
178    }
179
180    async fn get_changed_files_paginated(
181        &self,
182        url: Url,
183        file_filter: &FileFilter,
184    ) -> Result<Vec<FileObj>> {
185        let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
186        let mut files = vec![];
187        while let Some(ref endpoint) = url {
188            let request =
189                Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?;
190            let response = Self::send_api_request(
191                self.client.clone(),
192                request,
193                self.rate_limit_headers.clone(),
194                0,
195            )
196            .await;
197            if let Ok(response) = response {
198                url = Self::try_next_page(response.headers());
199                let files_list = if self.event_name != "pull_request" {
200                    let json_value: PushEventFiles = serde_json::from_str(&response.text().await?)
201                        .with_context(|| {
202                            "Failed to deserialize list of changed files from json response"
203                        })?;
204                    json_value.files
205                } else {
206                    serde_json::from_str::<Vec<GithubChangedFile>>(&response.text().await?)
207                        .with_context(|| {
208                            "Failed to deserialize list of file changes from Pull Request event."
209                        })?
210                };
211                for file in files_list {
212                    if let Some(patch) = file.patch {
213                        let diff = format!(
214                            "diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}",
215                            old = file.previous_filename.unwrap_or(file.filename.clone()),
216                            new = file.filename,
217                        );
218                        if let Some(file_obj) =
219                            parse_diff_from_buf(diff.as_bytes(), file_filter).first()
220                        {
221                            files.push(file_obj.to_owned());
222                        }
223                    }
224                }
225            }
226        }
227        Ok(files)
228    }
229
230    async fn post_feedback(
231        &self,
232        files: &[Arc<Mutex<FileObj>>],
233        feedback_inputs: FeedbackInput,
234        clang_versions: ClangVersions,
235    ) -> Result<u64> {
236        let tidy_checks_failed = tally_tidy_advice(files);
237        let format_checks_failed = tally_format_advice(files);
238        let mut comment = None;
239
240        if feedback_inputs.file_annotations {
241            self.post_annotations(files, feedback_inputs.style.as_str());
242        }
243        if feedback_inputs.step_summary {
244            comment = Some(self.make_comment(
245                files,
246                format_checks_failed,
247                tidy_checks_failed,
248                &clang_versions,
249                None,
250            ));
251            self.post_step_summary(comment.as_ref().unwrap());
252        }
253        self.set_exit_code(
254            format_checks_failed + tidy_checks_failed,
255            Some(format_checks_failed),
256            Some(tidy_checks_failed),
257        );
258
259        if feedback_inputs.thread_comments != ThreadComments::Off {
260            // post thread comment for PR or push event
261            if comment.as_ref().is_some_and(|c| c.len() > 65535) || comment.is_none() {
262                comment = Some(self.make_comment(
263                    files,
264                    format_checks_failed,
265                    tidy_checks_failed,
266                    &clang_versions,
267                    Some(65535),
268                ));
269            }
270            if let Some(repo) = &self.repo {
271                let is_pr = self.event_name == "pull_request";
272                let pr = self.pull_request.to_string() + "/";
273                let sha = self.sha.clone().unwrap() + "/";
274                let comments_url = self
275                    .api_url
276                    .join("repos/")?
277                    .join(format!("{}/", repo).as_str())?
278                    .join(if is_pr { "issues/" } else { "commits/" })?
279                    .join(if is_pr { pr.as_str() } else { sha.as_str() })?
280                    .join("comments")?;
281
282                self.update_comment(
283                    comments_url,
284                    &comment.unwrap(),
285                    feedback_inputs.no_lgtm,
286                    format_checks_failed + tidy_checks_failed == 0,
287                    feedback_inputs.thread_comments == ThreadComments::Update,
288                )
289                .await?;
290            }
291        }
292        if self.event_name == "pull_request"
293            && (feedback_inputs.tidy_review || feedback_inputs.format_review)
294        {
295            self.post_review(files, &feedback_inputs, &clang_versions)
296                .await?;
297        }
298        Ok(format_checks_failed + tidy_checks_failed)
299    }
300}
301
302#[cfg(test)]
303mod test {
304    use std::{
305        default::Default,
306        env,
307        io::Read,
308        path::{Path, PathBuf},
309        sync::{Arc, Mutex},
310    };
311
312    use regex::Regex;
313    use tempfile::{tempdir, NamedTempFile};
314
315    use super::GithubApiClient;
316    use crate::{
317        clang_tools::{
318            clang_format::{FormatAdvice, Replacement},
319            clang_tidy::{TidyAdvice, TidyNotification},
320            ClangVersions,
321        },
322        cli::FeedbackInput,
323        common_fs::{FileFilter, FileObj},
324        logger,
325        rest_api::{RestApiClient, USER_OUTREACH},
326    };
327
328    // ************************* tests for step-summary and output variables
329
330    async fn create_comment(
331        is_lgtm: bool,
332        fail_gh_out: bool,
333        fail_summary: bool,
334    ) -> (String, String) {
335        let tmp_dir = tempdir().unwrap();
336        let rest_api_client = GithubApiClient::new().unwrap();
337        logger::init().unwrap();
338        if env::var("ACTIONS_STEP_DEBUG").is_ok_and(|var| var == "true") {
339            assert!(rest_api_client.debug_enabled);
340            log::set_max_level(log::LevelFilter::Debug);
341        }
342        let mut files = vec![];
343        if !is_lgtm {
344            for _i in 0..65535 {
345                let filename = String::from("tests/demo/demo.cpp");
346                let mut file = FileObj::new(PathBuf::from(&filename));
347                let notes = vec![TidyNotification {
348                    filename,
349                    line: 0,
350                    cols: 0,
351                    severity: String::from("note"),
352                    rationale: String::from("A test dummy rationale"),
353                    diagnostic: String::from("clang-diagnostic-warning"),
354                    suggestion: vec![],
355                    fixed_lines: vec![],
356                }];
357                file.tidy_advice = Some(TidyAdvice {
358                    notes,
359                    patched: None,
360                });
361                let replacements = vec![Replacement {
362                    offset: 0,
363                    length: 0,
364                    value: Some(String::new()),
365                    line: 1,
366                    cols: 1,
367                }];
368                file.format_advice = Some(FormatAdvice {
369                    replacements,
370                    patched: None,
371                });
372                files.push(Arc::new(Mutex::new(file)));
373            }
374        }
375        let feedback_inputs = FeedbackInput {
376            style: if is_lgtm {
377                String::new()
378            } else {
379                String::from("file")
380            },
381            step_summary: true,
382            ..Default::default()
383        };
384        let mut step_summary_path = NamedTempFile::new_in(tmp_dir.path()).unwrap();
385        env::set_var(
386            "GITHUB_STEP_SUMMARY",
387            if fail_summary {
388                Path::new("not-a-file.txt")
389            } else {
390                step_summary_path.path()
391            },
392        );
393        let mut gh_out_path = NamedTempFile::new_in(tmp_dir.path()).unwrap();
394        env::set_var(
395            "GITHUB_OUTPUT",
396            if fail_gh_out {
397                Path::new("not-a-file.txt")
398            } else {
399                gh_out_path.path()
400            },
401        );
402        let clang_versions = ClangVersions {
403            format_version: Some("x.y.z".to_string()),
404            tidy_version: Some("x.y.z".to_string()),
405        };
406        rest_api_client
407            .post_feedback(&files, feedback_inputs, clang_versions)
408            .await
409            .unwrap();
410        let mut step_summary_content = String::new();
411        step_summary_path
412            .read_to_string(&mut step_summary_content)
413            .unwrap();
414        if !fail_summary {
415            assert!(&step_summary_content.contains(USER_OUTREACH));
416        }
417        let mut gh_out_content = String::new();
418        gh_out_path.read_to_string(&mut gh_out_content).unwrap();
419        if !fail_gh_out {
420            assert!(gh_out_content.starts_with("checks-failed="));
421        }
422        (step_summary_content, gh_out_content)
423    }
424
425    #[tokio::test]
426    async fn check_comment_concerns() {
427        let (comment, gh_out) = create_comment(false, false, false).await;
428        assert!(&comment.contains(":warning:\nSome files did not pass the configured checks!\n"));
429        let fmt_pattern = Regex::new(r"format-checks-failed=(\d+)\n").unwrap();
430        let tidy_pattern = Regex::new(r"tidy-checks-failed=(\d+)\n").unwrap();
431        for pattern in [fmt_pattern, tidy_pattern] {
432            let number = pattern
433                .captures(&gh_out)
434                .expect("found no number of checks-failed")
435                .get(1)
436                .unwrap()
437                .as_str()
438                .parse::<u64>()
439                .unwrap();
440            assert!(number > 0);
441        }
442    }
443
444    #[tokio::test]
445    async fn check_comment_lgtm() {
446        env::set_var("ACTIONS_STEP_DEBUG", "true");
447        let (comment, gh_out) = create_comment(true, false, false).await;
448        assert!(comment.contains(":heavy_check_mark:\nNo problems need attention."));
449        assert_eq!(
450            gh_out,
451            "checks-failed=0\nformat-checks-failed=0\ntidy-checks-failed=0\n"
452        );
453    }
454
455    #[tokio::test]
456    async fn fail_gh_output() {
457        env::set_var("ACTIONS_STEP_DEBUG", "true");
458        let (comment, gh_out) = create_comment(true, true, false).await;
459        assert!(&comment.contains(":heavy_check_mark:\nNo problems need attention."));
460        assert!(gh_out.is_empty());
461    }
462
463    #[tokio::test]
464    async fn fail_gh_summary() {
465        env::set_var("ACTIONS_STEP_DEBUG", "true");
466        let (comment, gh_out) = create_comment(true, false, true).await;
467        assert!(comment.is_empty());
468        assert_eq!(
469            gh_out,
470            "checks-failed=0\nformat-checks-failed=0\ntidy-checks-failed=0\n"
471        );
472    }
473
474    #[tokio::test]
475    async fn fail_get_local_diff() {
476        env::set_var("CI", "false");
477        let tmp_dir = tempdir().unwrap();
478        env::set_current_dir(tmp_dir.path()).unwrap();
479        let rest_client = GithubApiClient::new().unwrap();
480        let files = rest_client
481            .get_list_of_changed_files(&FileFilter::new(&[], vec![]))
482            .await;
483        assert!(files.is_err())
484    }
485}