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