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};
17use serde_json;
18
19use 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
28mod serde_structs;
30mod specific_api;
31use serde_structs::{GithubChangedFile, PushEventFiles};
32
33pub struct GithubApiClient {
35 client: Client,
37
38 pull_request: i64,
40
41 pub event_name: String,
43
44 api_url: Url,
46
47 repo: Option<String>,
49
50 sha: Option<String>,
52
53 pub debug_enabled: bool,
55
56 rate_limit_headers: RestApiRateLimitHeaders,
58}
59
60impl 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 fn start_log_group(&self, name: String) {
101 log::info!(target: "CI_LOG_GROUPING", "::group::{}", name);
102 }
103
104 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 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 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 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 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}