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