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