1use std::{
4 collections::HashMap,
5 env,
6 fs::OpenOptions,
7 io::{Read, Write},
8 path::{Path, PathBuf},
9 sync::{Arc, Mutex},
10};
11
12use anyhow::{anyhow, Context, Result};
13use reqwest::{Client, Method, Url};
14
15use crate::{
16 clang_tools::{clang_format::summarize_style, ClangVersions, ReviewComments},
17 cli::{FeedbackInput, LinesChangedOnly},
18 common_fs::{FileFilter, FileObj},
19 git::parse_diff_from_buf,
20 rest_api::{send_api_request, RestApiRateLimitHeaders, COMMENT_MARKER, USER_AGENT},
21};
22
23use super::{
24 serde_structs::{
25 FullReview, GithubChangedFile, PullRequestInfo, PushEventFiles, ReviewComment,
26 ReviewDiffComment, ThreadComment, REVIEW_DISMISSAL,
27 },
28 GithubApiClient, RestApiClient,
29};
30
31impl GithubApiClient {
32 pub fn new() -> Result<Self> {
34 let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or(String::from("unknown"));
35 let pull_request = {
36 match event_name.as_str() {
37 "pull_request" => {
38 let event_payload_path = env::var("GITHUB_EVENT_PATH")?;
40 let file_buf = &mut String::new();
42 OpenOptions::new()
43 .read(true)
44 .open(event_payload_path.clone())?
45 .read_to_string(file_buf)
46 .with_context(|| {
47 format!("Failed to read event payload at {event_payload_path}")
48 })?;
49 let payload =
50 serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(
51 file_buf,
52 )
53 .with_context(|| "Failed to deserialize event payload")?;
54 payload["number"].as_i64().unwrap_or(-1)
55 }
56 _ => -1,
57 }
58 };
59 let gh_api_url = env::var("GITHUB_API_URL").unwrap_or("https://api.github.com".to_string());
61 let api_url = Url::parse(gh_api_url.as_str())?;
62
63 Ok(GithubApiClient {
64 client: Client::builder()
65 .default_headers(Self::make_headers()?)
66 .user_agent(USER_AGENT)
67 .build()?,
68 pull_request,
69 event_name,
70 api_url,
71 repo: env::var("GITHUB_REPOSITORY").ok(),
72 sha: env::var("GITHUB_SHA").ok(),
73 debug_enabled: env::var("ACTIONS_STEP_DEBUG").is_ok_and(|val| &val == "true"),
74 rate_limit_headers: RestApiRateLimitHeaders {
75 reset: "x-ratelimit-reset".to_string(),
76 remaining: "x-ratelimit-remaining".to_string(),
77 retry: "retry-after".to_string(),
78 },
79 })
80 }
81
82 pub(super) async fn get_changed_files_paginated(
87 &self,
88 url: Url,
89 file_filter: &FileFilter,
90 lines_changed_only: &LinesChangedOnly,
91 ) -> Result<Vec<FileObj>> {
92 let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
93 let mut files = vec![];
94 while let Some(ref endpoint) = url {
95 let request =
96 Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?;
97 let response = send_api_request(&self.client, request, &self.rate_limit_headers)
98 .await
99 .with_context(|| "Failed to get paginated list of changed files")?;
100 url = Self::try_next_page(response.headers());
101 let files_list = if self.event_name != "pull_request" {
102 let json_value: PushEventFiles = serde_json::from_str(&response.text().await?)
103 .with_context(|| {
104 "Failed to deserialize list of changed files from json response"
105 })?;
106 json_value.files
107 } else {
108 serde_json::from_str::<Vec<GithubChangedFile>>(&response.text().await?)
109 .with_context(|| {
110 "Failed to deserialize list of file changes from Pull Request event."
111 })?
112 };
113 for file in files_list {
114 let ext = Path::new(&file.filename).extension().unwrap_or_default();
115 if !file_filter
116 .extensions
117 .contains(&ext.to_string_lossy().to_string())
118 {
119 continue;
120 }
121 if let Some(patch) = file.patch {
122 let diff = format!(
123 "diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}\n",
124 old = file.previous_filename.unwrap_or(file.filename.clone()),
125 new = file.filename,
126 );
127 if let Some(file_obj) =
128 parse_diff_from_buf(diff.as_bytes(), file_filter, lines_changed_only)
129 .first()
130 {
131 files.push(file_obj.to_owned());
132 }
133 } else if file.changes == 0 {
134 files.push(FileObj::new(PathBuf::from(file.filename)));
137 }
138 }
140 }
141 Ok(files)
142 }
143
144 pub fn post_step_summary(&self, comment: &String) {
146 if let Ok(gh_out) = env::var("GITHUB_STEP_SUMMARY") {
147 if let Ok(mut gh_out_file) = OpenOptions::new().append(true).open(gh_out) {
149 if let Err(e) = writeln!(gh_out_file, "\n{}\n", comment) {
150 log::error!("Could not write to GITHUB_STEP_SUMMARY file: {}", e);
151 }
152 } else {
153 log::error!("GITHUB_STEP_SUMMARY file could not be opened");
154 }
155 }
156 }
157
158 pub fn post_annotations(&self, files: &[Arc<Mutex<FileObj>>], style: &str) {
160 let style_guide = summarize_style(style);
161
162 for file in files {
164 let file = file.lock().unwrap();
165 if let Some(format_advice) = &file.format_advice {
166 let mut lines = Vec::new();
168 for replacement in &format_advice.replacements {
169 if !lines.contains(&replacement.line) {
170 lines.push(replacement.line);
171 }
172 }
173 if !lines.is_empty() {
175 println!(
176 "::notice file={name},title=Run clang-format on {name}::File {name} does not conform to {style_guide} style guidelines. (lines {line_set})",
177 name = &file.name.to_string_lossy().replace('\\', "/"),
178 line_set = lines.iter().map(|val| val.to_string()).collect::<Vec<_>>().join(","),
179 );
180 }
181 } if let Some(tidy_advice) = &file.tidy_advice {
187 for note in &tidy_advice.notes {
188 if note.filename == file.name.to_string_lossy().replace('\\', "/") {
189 println!(
190 "::{severity} file={file},line={line},title={file}:{line}:{cols} [{diag}]::{info}",
191 severity = if note.severity == *"note" { "notice".to_string() } else {note.severity.clone()},
192 file = note.filename,
193 line = note.line,
194 cols = note.cols,
195 diag = note.diagnostic,
196 info = note.rationale,
197 );
198 }
199 }
200 }
201 }
202 }
203
204 pub async fn update_comment(
206 &self,
207 url: Url,
208 comment: &String,
209 no_lgtm: bool,
210 is_lgtm: bool,
211 update_only: bool,
212 ) -> Result<()> {
213 let comment_url = self
214 .remove_bot_comments(&url, !update_only || (is_lgtm && no_lgtm))
215 .await?;
216 if !is_lgtm || !no_lgtm {
217 let payload = HashMap::from([("body", comment)]);
218 let req_meth = if comment_url.is_some() {
220 Method::PATCH
221 } else {
222 Method::POST
223 };
224 let request = Self::make_api_request(
225 &self.client,
226 comment_url.unwrap_or(url),
227 req_meth,
228 Some(serde_json::json!(&payload).to_string()),
229 None,
230 )?;
231 match send_api_request(&self.client, request, &self.rate_limit_headers).await {
232 Ok(response) => {
233 Self::log_response(response, "Failed to post thread comment").await;
234 }
235 Err(e) => {
236 log::error!("Failed to post thread comment: {e:?}");
237 }
238 }
239 }
240 Ok(())
241 }
242
243 async fn remove_bot_comments(&self, url: &Url, delete: bool) -> Result<Option<Url>> {
245 let mut comment_url = None;
246 let mut comments_url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
247 let repo = format!(
248 "repos/{}{}/comments",
249 self.repo.as_ref().expect("Repo name unknown."),
251 if self.event_name == "pull_request" {
252 "/issues"
253 } else {
254 ""
255 },
256 );
257 let base_comment_url = self.api_url.join(&repo).unwrap();
258 while let Some(ref endpoint) = comments_url {
259 let request =
260 Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?;
261 let result = send_api_request(&self.client, request, &self.rate_limit_headers).await;
262 match result {
263 Err(e) => {
264 log::error!("Failed to get list of existing thread comments: {e:?}");
265 return Ok(comment_url);
266 }
267 Ok(response) => {
268 if !response.status().is_success() {
269 Self::log_response(
270 response,
271 "Failed to get list of existing thread comments",
272 )
273 .await;
274 return Ok(comment_url);
275 }
276 comments_url = Self::try_next_page(response.headers());
277 let payload =
278 serde_json::from_str::<Vec<ThreadComment>>(&response.text().await?);
279 match payload {
280 Err(e) => {
281 log::error!(
282 "Failed to deserialize list of existing thread comments: {e:?}"
283 );
284 continue;
285 }
286 Ok(payload) => {
287 for comment in payload {
288 if comment.body.starts_with(COMMENT_MARKER) {
289 log::debug!(
290 "Found cpp-linter comment id {} from user {} ({})",
291 comment.id,
292 comment.user.login,
293 comment.user.id,
294 );
295 let this_comment_url = Url::parse(
296 format!("{base_comment_url}/{}", comment.id).as_str(),
297 )?;
298 if delete || comment_url.is_some() {
299 let del_url = if let Some(last_url) = &comment_url {
304 last_url
305 } else {
306 &this_comment_url
307 };
308 let req = Self::make_api_request(
309 &self.client,
310 del_url.as_str(),
311 Method::DELETE,
312 None,
313 None,
314 )?;
315 match send_api_request(
316 &self.client,
317 req,
318 &self.rate_limit_headers,
319 )
320 .await
321 {
322 Ok(result) => {
323 if !result.status().is_success() {
324 Self::log_response(
325 result,
326 "Failed to delete old thread comment",
327 )
328 .await;
329 }
330 }
331 Err(e) => {
332 log::error!(
333 "Failed to delete old thread comment: {e:?}"
334 )
335 }
336 }
337 }
338 if !delete {
339 comment_url = Some(this_comment_url)
340 }
341 }
342 }
343 }
344 }
345 }
346 }
347 }
348 Ok(comment_url)
349 }
350
351 pub async fn post_review(
355 &self,
356 files: &[Arc<Mutex<FileObj>>],
357 feedback_input: &FeedbackInput,
358 clang_versions: &ClangVersions,
359 ) -> Result<()> {
360 let url = self
361 .api_url
362 .join("repos/")?
363 .join(
364 format!(
365 "{}/",
366 self.repo.as_ref().ok_or(anyhow!("Repo name unknown"))?
368 )
369 .as_str(),
370 )?
371 .join("pulls/")?
372 .join(self.pull_request.to_string().as_str())?;
374 let request = Self::make_api_request(&self.client, url.as_str(), Method::GET, None, None)?;
375 let response = send_api_request(&self.client, request, &self.rate_limit_headers);
376
377 let url = Url::parse(format!("{}/", url).as_str())?.join("reviews")?;
378 let dismissal = self.dismiss_outdated_reviews(&url);
379 match response.await {
380 Ok(response) => {
381 match serde_json::from_str::<PullRequestInfo>(&response.text().await?) {
382 Err(e) => {
383 log::error!("Failed to deserialize PR info: {e:?}");
384 return dismissal.await;
385 }
386 Ok(pr_info) => {
387 if pr_info.draft || pr_info.state != "open" {
388 return dismissal.await;
389 }
390 }
391 }
392 }
393 Err(e) => {
394 log::error!("Failed to get PR info from {e:?}");
395 return dismissal.await;
396 }
397 }
398
399 let summary_only = ["true", "on", "1"].contains(
400 &env::var("CPP_LINTER_PR_REVIEW_SUMMARY_ONLY")
401 .unwrap_or("false".to_string())
402 .as_str(),
403 );
404
405 let mut review_comments = ReviewComments::default();
406 for file in files {
407 let file = file.lock().unwrap();
408 file.make_suggestions_from_patch(&mut review_comments, summary_only)?;
409 }
410 let has_no_changes =
411 review_comments.full_patch[0].is_empty() && review_comments.full_patch[1].is_empty();
412 if has_no_changes && feedback_input.no_lgtm {
413 log::debug!("Not posting an approved review because `no-lgtm` is true");
414 return dismissal.await;
415 }
416 let mut payload = FullReview {
417 event: if feedback_input.passive_reviews {
418 String::from("COMMENT")
419 } else if has_no_changes && review_comments.comments.is_empty() {
420 String::from("APPROVE")
422 } else {
423 String::from("REQUEST_CHANGES")
424 },
425 body: String::new(),
426 comments: vec![],
427 };
428 payload.body = review_comments.summarize(clang_versions);
429 if !summary_only {
430 payload.comments = {
431 let mut comments = vec![];
432 for comment in review_comments.comments {
433 comments.push(ReviewDiffComment::from(comment));
434 }
435 comments
436 };
437 }
438 dismissal.await?; let request = Self::make_api_request(
440 &self.client,
441 url,
442 Method::POST,
443 Some(
444 serde_json::to_string(&payload)
445 .with_context(|| "Failed to serialize PR review to json string")?,
446 ),
447 None,
448 )?;
449 match send_api_request(&self.client, request, &self.rate_limit_headers).await {
450 Ok(response) => {
451 if !response.status().is_success() {
452 Self::log_response(response, "Failed to post a new PR review").await;
453 }
454 }
455 Err(e) => {
456 log::error!("Failed to post a new PR review: {e:?}");
457 }
458 }
459 Ok(())
460 }
461
462 async fn dismiss_outdated_reviews(&self, url: &Url) -> Result<()> {
464 let mut url_ = Some(Url::parse_with_params(url.as_str(), [("page", "1")])?);
465 while let Some(ref endpoint) = url_ {
466 let request =
467 Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?;
468 let result = send_api_request(&self.client, request, &self.rate_limit_headers).await;
469 match result {
470 Err(e) => {
471 log::error!("Failed to get a list of existing PR reviews: {e:?}");
472 return Ok(());
473 }
474 Ok(response) => {
475 if !response.status().is_success() {
476 Self::log_response(response, "Failed to get a list of existing PR reviews")
477 .await;
478 return Ok(());
479 }
480 url_ = Self::try_next_page(response.headers());
481 match serde_json::from_str::<Vec<ReviewComment>>(&response.text().await?) {
482 Err(e) => {
483 log::error!("Unable to serialize JSON about review comments: {e:?}");
484 return Ok(());
485 }
486 Ok(payload) => {
487 for review in payload {
488 if let Some(body) = &review.body {
489 if body.starts_with(COMMENT_MARKER)
490 && !(["PENDING", "DISMISSED"]
491 .contains(&review.state.as_str()))
492 {
493 if let Ok(dismiss_url) = url.join(
495 format!("reviews/{}/dismissals", review.id).as_str(),
496 ) {
497 if let Ok(req) = Self::make_api_request(
498 &self.client,
499 dismiss_url,
500 Method::PUT,
501 Some(REVIEW_DISMISSAL.to_string()),
502 None,
503 ) {
504 match send_api_request(
505 &self.client,
506 req,
507 &self.rate_limit_headers,
508 )
509 .await
510 {
511 Ok(result) => {
512 if !result.status().is_success() {
513 Self::log_response(
514 result,
515 "Failed to dismiss outdated review",
516 )
517 .await;
518 }
519 }
520 Err(e) => {
521 log::error!(
522 "Failed to dismiss outdated review: {e:}"
523 );
524 }
525 }
526 }
527 }
528 }
529 }
530 }
531 }
532 }
533 }
534 }
535 }
536 Ok(())
537 }
538}