Skip to main content

git_bot_feedback/client/github/
mod.rs

1//! This module holds functionality specific to using Github's REST API.
2//!
3//! In the root module, we just implement the RestApiClient trait.
4//! In other (private) submodules we implement behavior specific to Github's REST API.
5
6use std::{
7    env,
8    fs::OpenOptions,
9    io::{self, Write},
10};
11
12use async_trait::async_trait;
13use reqwest::{Client, Method, Url};
14
15use crate::{
16    FileAnnotation, OutputVariable, ReviewAction, ReviewOptions, ThreadCommentOptions,
17    client::{
18        ClientError, RestApiClient, RestApiRateLimitHeaders,
19        common::{PullRequestInfo, PullRequestState},
20    },
21};
22mod graphql;
23mod serde_structs;
24use serde_structs::{FullReview, ReviewDiffComment};
25mod specific_api;
26
27#[cfg(feature = "file-changes")]
28use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
29#[cfg(feature = "file-changes")]
30use std::{collections::HashMap, path::Path};
31
32/// A structure to work with Github REST API.
33pub struct GithubApiClient {
34    /// The HTTP request client to be used for all REST API calls.
35    client: Client,
36
37    /// The CI run's event payload from the webhook that triggered the workflow.
38    pull_request: Option<PullRequestInfo>,
39
40    /// The name of the event that was triggered when running cpp_linter.
41    pub event_name: String,
42
43    /// The value of the `GITHUB_API_URL` environment variable.
44    api_url: Url,
45
46    /// The value of the `GITHUB_GRAPHQL_URL` environment variable.
47    graphql_url: Url,
48
49    /// The value of the `GITHUB_REPOSITORY` environment variable.
50    repo: String,
51
52    /// The value of the `GITHUB_SHA` environment variable.
53    sha: String,
54
55    /// The value of the `ACTIONS_STEP_DEBUG` environment variable.
56    pub debug_enabled: bool,
57
58    /// The response header names that describe the rate limit status.
59    rate_limit_headers: RestApiRateLimitHeaders,
60}
61
62fn step_summary_url() -> Option<String> {
63    let server_url = env::var("GITHUB_SERVER_URL").ok()?;
64    let repo = env::var("GITHUB_REPOSITORY").ok()?;
65    let run_id = env::var("GITHUB_RUN_ID").ok()?;
66    Some(format!(
67        "{}/{repo}/actions/runs/{run_id}",
68        server_url.trim_end_matches('/')
69    ))
70}
71
72// implement the RestApiClient trait for the GithubApiClient
73#[async_trait]
74impl RestApiClient for GithubApiClient {
75    fn client_kind(&self) -> String {
76        "github".to_string()
77    }
78
79    /// This prints a line to indicate the beginning of a related group of [`log`] statements.
80    ///
81    /// For apps' [`log`] implementations, this function's [`log::info`] output needs to have
82    /// no prefixed data.
83    /// Such behavior can be identified by the log target `"CI_LOG_GROUPING"`.
84    ///
85    /// ```
86    /// # struct MyAppLogger;
87    /// impl log::Log for MyAppLogger {
88    /// #    fn enabled(&self, metadata: &log::Metadata) -> bool {
89    /// #        log::max_level() > metadata.level()
90    /// #    }
91    ///     fn log(&self, record: &log::Record) {
92    ///         if record.target() == "CI_LOG_GROUPING" {
93    ///             println!("{}", record.args());
94    ///         } else {
95    ///             println!(
96    ///                 "[{:>5}]{}: {}",
97    ///                 record.level().as_str(),
98    ///                 record.module_path().unwrap_or_default(),
99    ///                 record.args()
100    ///             );
101    ///         }
102    ///     }
103    /// #    fn flush(&self) {}
104    /// }
105    /// ```
106    fn start_log_group(&self, name: &str) {
107        log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
108    }
109
110    /// This prints a line to indicate the ending of a related group of [`log`] statements.
111    ///
112    /// See also [`GithubApiClient::start_log_group`] about special handling of
113    /// the log target `"CI_LOG_GROUPING"`.
114    fn end_log_group(&self, _name: &str) {
115        log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
116    }
117
118    fn event_name(&self) -> Option<String> {
119        Some(self.event_name.clone())
120    }
121
122    fn is_debug_enabled(&self) -> bool {
123        self.debug_enabled
124    }
125
126    fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError> {
127        self.client = Client::builder()
128            .default_headers(Self::make_headers()?)
129            .user_agent(user_agent)
130            .build()?;
131        Ok(())
132    }
133
134    async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
135        env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
136        let comments_url = match &self.pull_request {
137            Some(pr_event) => {
138                if pr_event.locked {
139                    return Ok(()); // cannot comment on locked PRs
140                }
141                self.api_url.join(
142                    format!("repos/{}/issues/{}/comments", self.repo, pr_event.number).as_str(),
143                )?
144            }
145            None => self
146                .api_url
147                .join(format!("repos/{}/commits/{}/comments", self.repo, self.sha).as_str())?,
148        };
149        self.update_comment(comments_url, options).await
150    }
151
152    #[inline]
153    fn is_pr_event(&self) -> bool {
154        self.pull_request.is_some()
155    }
156
157    fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
158        let gh_out = env::var("GITHUB_STEP_SUMMARY")
159            .map_err(|e| ClientError::env_var("GITHUB_STEP_SUMMARY", e))?;
160        // step summary MD file can be overwritten/removed in CI runners
161        match OpenOptions::new().append(true).open(gh_out) {
162            Ok(mut gh_out_file) => {
163                writeln!(&mut gh_out_file, "\n{comment}\n")
164                    .map_err(|e| ClientError::io("write to GITHUB_STEP_SUMMARY file", e))?;
165                if let Some(step_summary_url) = step_summary_url() {
166                    log::info!("View step summary at {step_summary_url}");
167                }
168                Ok(())
169            }
170            Err(e) => Err(ClientError::io("open GITHUB_STEP_SUMMARY file", e)),
171        }
172    }
173
174    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
175        if vars.is_empty() {
176            // Should probably be an error. This check is only here to prevent needlessly
177            // fetching the env var GITHUB_OUTPUT value and opening the referenced file.
178            return Ok(());
179        }
180        let gh_out =
181            env::var("GITHUB_OUTPUT").map_err(|e| ClientError::env_var("GITHUB_OUTPUT", e))?;
182        match OpenOptions::new().append(true).open(gh_out) {
183            Ok(mut gh_out_file) => {
184                for out_var in vars {
185                    out_var.validate()?;
186                    writeln!(&mut gh_out_file, "{out_var}")
187                        .map_err(|e| ClientError::io("write to GITHUB_OUTPUT file", e))?;
188                }
189                Ok(())
190            }
191            Err(e) => Err(ClientError::io("open GITHUB_OUTPUT file", e)),
192        }
193    }
194
195    fn write_file_annotations(&self, annotations: &[FileAnnotation]) -> Result<(), ClientError> {
196        if annotations.is_empty() {
197            // Should probably be an error.
198            // This check is only here to prevent needlessly locking stdout.
199            return Ok(());
200        }
201        let stdout = io::stdout();
202        let mut handle = stdout.lock();
203        for annotation in annotations {
204            writeln!(&mut handle, "{}", annotation.fmt_github())
205                .map_err(|e| ClientError::io("write to file annotation to stdout", e))?;
206        }
207        handle
208            .flush()
209            .map_err(|e| ClientError::io("flush stdout with file annotations", e))?;
210        Ok(())
211    }
212
213    #[cfg(feature = "file-changes")]
214    #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
215    async fn get_list_of_changed_files(
216        &self,
217        file_filter: &FileFilter,
218        lines_changed_only: &LinesChangedOnly,
219        _base_diff: Option<String>,
220        _ignore_index: bool,
221    ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
222        use crate::client::github::serde_structs::GitHubChangedFileStatus;
223
224        let (url, is_pr) = match &self.pull_request {
225            Some(pr_event) => (
226                self.api_url.join(
227                    format!("repos/{}/pulls/{}/files", self.repo, pr_event.number).as_str(),
228                )?,
229                true,
230            ),
231            None => (
232                self.api_url
233                    .join(format!("repos/{}/commits/{}", self.repo, self.sha).as_str())?,
234                false,
235            ),
236        };
237        let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
238        let mut files: HashMap<String, FileDiffLines> = HashMap::new();
239        while let Some(endpoint) = url.take() {
240            let request = self.make_api_request(&self.client, endpoint, Method::GET, None, None)?;
241            let response = self
242                .send_api_request(&self.client, request, &self.rate_limit_headers)
243                .await
244                .map_err(|e| e.add_request_context("get list of changed files"))?;
245            url = self.try_next_page(response.headers());
246            if let Err(e) = response.error_for_status_ref() {
247                if let Ok(body) = response.text().await {
248                    log::error!("Failed to get list of changed files: {e:?}\n{body}");
249                }
250                return Err(
251                    ClientError::Request(e).add_request_context("get list of changed files")
252                );
253            }
254            let body = response.text().await?;
255            let files_list = if !is_pr {
256                let json_value: serde_structs::PushEventFiles = serde_json::from_str(&body)
257                    .map_err(|e| ClientError::json("deserialize list of changed files", e))?;
258                json_value.files
259            } else {
260                serde_json::from_str::<Vec<serde_structs::GithubChangedFile>>(&body)
261                    .map_err(|e| ClientError::json("deserialize list of changed files", e))?
262            };
263            for file in files_list {
264                let ext = Path::new(&file.filename).extension().unwrap_or_default();
265                if !file_filter
266                    .extensions
267                    .contains(&ext.to_string_lossy().to_string())
268                    // skip files that were removed, since there's no content to analyze.
269                    || matches!(file.status, GitHubChangedFileStatus::Removed)
270                {
271                    continue;
272                }
273                if let Some(patch) = file.patch {
274                    let diff = format!(
275                        "diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}\n",
276                        old = file.previous_filename.unwrap_or(file.filename.clone()),
277                        new = file.filename,
278                    );
279                    for (name, info) in parse_diff(&diff, file_filter, lines_changed_only)? {
280                        files.entry(name).or_insert(info);
281                    }
282                } else if file.changes == 0 || matches!(lines_changed_only, LinesChangedOnly::Off) {
283                    // file may have been only renamed.
284                    // include it in case files-changed-only is enabled.
285                    files.entry(file.filename).or_default();
286                } else {
287                    log::warn!(
288                        "File {} has no patch info about {} changes, skipping",
289                        file.filename,
290                        file.changes
291                    );
292                }
293                // else changes are too big (per git server limits) or we don't care
294            }
295        }
296
297        Ok(files)
298    }
299
300    async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
301        if let Some(pr_info) = self.pull_request.as_ref() {
302            if pr_info.locked
303                || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
304            {
305                return Ok(());
306            }
307            env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
308
309            // Check existing comments to see if we can reuse any of them.
310            // This also removes duplicate comments (if any) from the `options.comments`.
311            let keep_reviews = self.check_reused_comments(options).await?;
312            // Next hide/resolve any previous reviews that are completely outdated.
313            let url = self
314                .api_url
315                .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
316            self.hide_outdated_reviews(url, keep_reviews, &options.marker)
317                .await?;
318        }
319        Ok(())
320    }
321
322    async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
323        if let Some(pr_info) = self.pull_request.as_ref() {
324            if (!options.allow_draft && pr_info.draft)
325                || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
326                || pr_info.locked
327            {
328                return Ok(());
329            }
330            env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
331            let url = self
332                .api_url
333                .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
334            let payload = FullReview {
335                event: match options.action {
336                    ReviewAction::Comment => String::from("COMMENT"),
337                    ReviewAction::Approve => String::from("APPROVE"),
338                    ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
339                },
340                body: if options.summary.starts_with(&options.marker) {
341                    options.summary.clone()
342                } else {
343                    format!("{}{}", options.marker, options.summary)
344                },
345                comments: options
346                    .comments
347                    .iter()
348                    .map(ReviewDiffComment::from)
349                    .map(|mut r| {
350                        if !r.body.starts_with(&options.marker) {
351                            r.body = format!("{}{}", options.marker, r.body);
352                        }
353                        r
354                    })
355                    .collect(),
356            };
357            let request = self.make_api_request(
358                &self.client,
359                url,
360                Method::POST,
361                Some(
362                    serde_json::to_string(&payload)
363                        .map_err(|e| ClientError::json("serialize PR review payload", e))?,
364                ),
365                None,
366            )?;
367            let response = self
368                .send_api_request(&self.client, request, &self.rate_limit_headers)
369                .await;
370            match response {
371                Ok(response) => {
372                    self.log_response(response, "Failed to post PR review")
373                        .await;
374                }
375                Err(e) => {
376                    return Err(e.add_request_context("post PR review"));
377                }
378            }
379        }
380        Ok(())
381    }
382}