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