1use std::env;
7use std::fs::OpenOptions;
8use std::io::Write;
9use std::sync::{Arc, Mutex};
10
11use anyhow::{Context, Result};
13use reqwest::{
14 header::{HeaderMap, HeaderValue, AUTHORIZATION},
15 Client, Method, Url,
16};
17
18use 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
27mod serde_structs;
29mod specific_api;
30
31pub struct GithubApiClient {
33 client: Client,
35
36 pull_request: i64,
38
39 pub event_name: String,
41
42 api_url: Url,
44
45 repo: Option<String>,
47
48 sha: Option<String>,
50
51 pub debug_enabled: bool,
53
54 rate_limit_headers: RestApiRateLimitHeaders,
56}
57
58impl 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 fn start_log_group(&self, name: String) {
99 log::info!(target: "CI_LOG_GROUPING", "::group::{}", name);
100 }
101
102 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 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 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 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 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}