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    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    /// Instantiate a [`GithubApiClient`] object.
31    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                    // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub.
37                    let event_payload_path = env::var("GITHUB_EVENT_PATH")?;
38                    // event payload JSON file can be overwritten/removed in CI runners
39                    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        // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub.
58        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    /// Append step summary to CI workflow's summary page.
81    pub fn post_step_summary(&self, comment: &String) {
82        if let Ok(gh_out) = env::var("GITHUB_STEP_SUMMARY") {
83            // step summary MD file can be overwritten/removed in CI runners
84            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    /// Post file annotations.
95    pub fn post_annotations(&self, files: &[Arc<Mutex<FileObj>>], style: &str) {
96        let style_guide = summarize_style(style);
97
98        // iterate over clang-format advice and post annotations
99        for file in files {
100            let file = file.lock().unwrap();
101            if let Some(format_advice) = &file.format_advice {
102                // assemble a list of line numbers
103                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                // post annotation if any applicable lines were formatted
110                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            } // end format_advice iterations
118
119            // iterate over clang-tidy advice and post annotations
120            // The tidy_advice vector is parallel to the files vector; meaning it serves as a file filterer.
121            // lines are already filter as specified to clang-tidy CLI.
122            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    /// Update existing comment or remove old comment(s) and post a new comment
141    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            // log::debug!("payload body:\n{:?}", payload);
155            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    /// Remove thread comments previously posted by cpp-linter.
187    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            // if we got here, then we know it is on a CI runner as self.repo should be known
193            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                                        // if not updating: remove all outdated comments
249                                        // if updating: remove all outdated comments except the last one
250
251                                        // use last saved comment_url (if not None) or current comment url
252                                        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    /// Post a PR review with code suggestions.
302    ///
303    /// Note: `--no-lgtm` is applied when nothing is suggested.
304    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                    // if we got here, then we know self.repo should be known
317                    self.repo.as_ref().ok_or(anyhow!("Repo name unknown"))?
318                )
319                .as_str(),
320            )?
321            .join("pulls/")?
322            // if we got here, then we know that it is a self.pull_request is a valid value
323            .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                // if patches have no changes AND there are no comments about clang-tidy diagnostics
376                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?; // free up the `url` variable
394        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    /// Dismiss any outdated reviews generated by cpp-linter.
425    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                                        // dismiss outdated review
462                                        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}