cpp_linter/rest_api/github/
specific_api.rs

1//! This submodule implements functionality exclusively specific to Github's REST API.
2
3use 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    /// Instantiate a [`GithubApiClient`] object.
33    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                    // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub.
39                    let event_payload_path = env::var("GITHUB_EVENT_PATH")?;
40                    // event payload JSON file can be overwritten/removed in CI runners
41                    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        // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub.
60        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    /// A way to get the list of changed files using REST API calls that employ a paginated response.
83    ///
84    /// This is a helper to [`Self::get_list_of_changed_files()`] but takes a formulated `url`
85    /// endpoint based on the context of the triggering CI event.
86    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                    // file may have been only renamed.
135                    // include it in case files-changed-only is enabled.
136                    files.push(FileObj::new(PathBuf::from(file.filename)));
137                }
138                // else changes are too big or we don't care
139            }
140        }
141        Ok(files)
142    }
143
144    /// Append step summary to CI workflow's summary page.
145    pub fn post_step_summary(&self, comment: &String) {
146        if let Ok(gh_out) = env::var("GITHUB_STEP_SUMMARY") {
147            // step summary MD file can be overwritten/removed in CI runners
148            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    /// Post file annotations.
159    pub fn post_annotations(&self, files: &[Arc<Mutex<FileObj>>], style: &str) {
160        let style_guide = summarize_style(style);
161
162        // iterate over clang-format advice and post annotations
163        for file in files {
164            let file = file.lock().unwrap();
165            if let Some(format_advice) = &file.format_advice {
166                // assemble a list of line numbers
167                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                // post annotation if any applicable lines were formatted
174                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            } // end format_advice iterations
182
183            // iterate over clang-tidy advice and post annotations
184            // The tidy_advice vector is parallel to the files vector; meaning it serves as a file filterer.
185            // lines are already filter as specified to clang-tidy CLI.
186            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    /// Update existing comment or remove old comment(s) and post a new comment
205    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            // log::debug!("payload body:\n{:?}", payload);
219            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    /// Remove thread comments previously posted by cpp-linter.
244    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            // if we got here, then we know it is on a CI runner as self.repo should be known
250            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                                        // if not updating: remove all outdated comments
300                                        // if updating: remove all outdated comments except the last one
301
302                                        // use last saved comment_url (if not None) or current comment url
303                                        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    /// Post a PR review with code suggestions.
352    ///
353    /// Note: `--no-lgtm` is applied when nothing is suggested.
354    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                    // if we got here, then we know self.repo should be known
367                    self.repo.as_ref().ok_or(anyhow!("Repo name unknown"))?
368                )
369                .as_str(),
370            )?
371            .join("pulls/")?
372            // if we got here, then we know that it is a self.pull_request is a valid value
373            .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                // if patches have no changes AND there are no comments about clang-tidy diagnostics
421                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?; // free up the `url` variable
439        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    /// Dismiss any outdated reviews generated by cpp-linter.
463    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                                        // dismiss outdated review
494                                        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}