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 crate::{
7    OutputVariable, ThreadCommentOptions,
8    client::{RestApiClient, RestApiRateLimitHeaders},
9    error::RestClientError,
10};
11use reqwest::{
12    Client, Url,
13    header::{AUTHORIZATION, HeaderMap, HeaderValue},
14};
15use std::{env, fs::OpenOptions, io::Write};
16mod serde_structs;
17mod specific_api;
18
19#[cfg(feature = "file-changes")]
20use crate::{FileDiffLines, FileFilter, LinesChangedOnly, client::send_api_request, parse_diff};
21#[cfg(feature = "file-changes")]
22use reqwest::Method;
23#[cfg(feature = "file-changes")]
24use std::{collections::HashMap, path::Path};
25
26/// A structure to work with Github REST API.
27pub struct GithubApiClient {
28    /// The HTTP request client to be used for all REST API calls.
29    client: Client,
30
31    /// The CI run's event payload from the webhook that triggered the workflow.
32    pull_request: i64,
33
34    /// The name of the event that was triggered when running cpp_linter.
35    pub event_name: String,
36
37    /// The value of the `GITHUB_API_URL` environment variable.
38    api_url: Url,
39
40    /// The value of the `GITHUB_REPOSITORY` environment variable.
41    repo: String,
42
43    /// The value of the `GITHUB_SHA` environment variable.
44    sha: String,
45
46    /// The value of the `ACTIONS_STEP_DEBUG` environment variable.
47    pub debug_enabled: bool,
48
49    /// The response header names that describe the rate limit status.
50    rate_limit_headers: RestApiRateLimitHeaders,
51}
52
53// implement the RestApiClient trait for the GithubApiClient
54impl RestApiClient for GithubApiClient {
55    /// This prints a line to indicate the beginning of a related group of [`log`] statements.
56    ///
57    /// For apps' [`log`] implementations, this function's [`log::info`] output needs to have
58    /// no prefixed data.
59    /// Such behavior can be identified by the log target `"CI_LOG_GROUPING"`.
60    ///
61    /// ```
62    /// # struct MyAppLogger;
63    /// impl log::Log for MyAppLogger {
64    /// #    fn enabled(&self, metadata: &log::Metadata) -> bool {
65    /// #        log::max_level() > metadata.level()
66    /// #    }
67    ///     fn log(&self, record: &log::Record) {
68    ///         if record.target() == "CI_LOG_GROUPING" {
69    ///             println!("{}", record.args());
70    ///         } else {
71    ///             println!(
72    ///                 "[{:>5}]{}: {}",
73    ///                 record.level().as_str(),
74    ///                 record.module_path().unwrap_or_default(),
75    ///                 record.args()
76    ///             );
77    ///         }
78    ///     }
79    /// #    fn flush(&self) {}
80    /// }
81    /// ```
82    fn start_log_group(name: &str) {
83        log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
84    }
85
86    /// This prints a line to indicate the ending of a related group of [`log`] statements.
87    ///
88    /// See also [`GithubApiClient::start_log_group`] about special handling of
89    /// the log target `"CI_LOG_GROUPING"`.
90    fn end_log_group() {
91        log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
92    }
93
94    fn make_headers() -> Result<HeaderMap<HeaderValue>, RestClientError> {
95        let mut headers = HeaderMap::new();
96        headers.insert(
97            "Accept",
98            HeaderValue::from_str("application/vnd.github.raw+json")?,
99        );
100        if let Ok(token) = env::var("GITHUB_TOKEN") {
101            log::debug!("Using auth token from GITHUB_TOKEN environment variable");
102            let mut val = HeaderValue::from_str(format!("token {token}").as_str())?;
103            val.set_sensitive(true);
104            headers.insert(AUTHORIZATION, val);
105        } else {
106            log::warn!(
107                "No GITHUB_TOKEN environment variable found! Permission to post comments may be unsatisfied."
108            );
109        }
110        Ok(headers)
111    }
112
113    async fn post_thread_comment(
114        &self,
115        options: ThreadCommentOptions,
116    ) -> Result<(), RestClientError> {
117        let is_pr = self.is_pr_event();
118        let comments_url = self
119            .api_url
120            .join("repos/")?
121            .join(format!("{}/", self.repo).as_str())?
122            .join(if is_pr { "issues/" } else { "commits/" })?
123            .join(
124                format!(
125                    "{}/",
126                    if is_pr {
127                        self.pull_request.to_string()
128                    } else {
129                        self.sha.clone()
130                    }
131                )
132                .as_str(),
133            )?
134            .join("comments")?;
135
136        self.update_comment(comments_url, options).await
137    }
138
139    #[inline]
140    fn is_pr_event(&self) -> bool {
141        self.pull_request > 0
142    }
143
144    fn append_step_summary(comment: &str) -> Result<(), RestClientError> {
145        if let Ok(gh_out) = env::var("GITHUB_STEP_SUMMARY") {
146            // step summary MD file can be overwritten/removed in CI runners
147            return match OpenOptions::new().append(true).open(gh_out) {
148                Ok(mut gh_out_file) => {
149                    let result = writeln!(&mut gh_out_file, "\n{comment}\n");
150                    if let Err(e) = &result {
151                        log::error!("Could not write to GITHUB_STEP_SUMMARY file: {e}");
152                    }
153                    result.map_err(RestClientError::Io)
154                }
155                Err(e) => {
156                    log::error!("GITHUB_STEP_SUMMARY file could not be opened: {e}");
157                    Err(RestClientError::Io(e))
158                }
159            };
160        }
161        Ok(())
162    }
163
164    fn write_output_variables(vars: &[OutputVariable]) -> Result<(), RestClientError> {
165        if vars.is_empty() {
166            // Should probably be an error. This check is only here to prevent needlessly
167            // fetching the env var GITHUB_OUTPUT value and opening the referenced file.
168            return Ok(());
169        }
170        if let Ok(gh_out) = env::var("GITHUB_OUTPUT") {
171            return match OpenOptions::new().append(true).open(gh_out) {
172                Ok(mut gh_out_file) => {
173                    for out_var in vars {
174                        if !out_var.validate() {
175                            return Err(RestClientError::OutputVarError(out_var.clone()));
176                        }
177                        if let Err(e) =
178                            writeln!(&mut gh_out_file, "{}={}\n", out_var.name, out_var.value)
179                        {
180                            log::error!("Could not write to GITHUB_OUTPUT file: {e}");
181                            return Err(RestClientError::Io(e));
182                        }
183                    }
184                    Ok(())
185                }
186                Err(e) => {
187                    log::error!("GITHUB_OUTPUT file could not be opened: {e}");
188                    Err(RestClientError::Io(e))
189                }
190            };
191        }
192        Ok(())
193    }
194
195    #[cfg(feature = "file-changes")]
196    #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
197    async fn get_list_of_changed_files(
198        &self,
199        file_filter: &FileFilter,
200        lines_changed_only: &LinesChangedOnly,
201    ) -> Result<HashMap<String, FileDiffLines>, RestClientError> {
202        let is_pr = self.is_pr_event();
203        let url_path = if is_pr {
204            format!("pulls/{}/files", self.pull_request)
205        } else {
206            format!("commits/{}", self.sha)
207        };
208        let url = self
209            .api_url
210            .join("repos/")?
211            .join(format!("{}/", &self.repo).as_str())?
212            .join(url_path.as_str())?;
213        let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
214        let mut files: HashMap<String, FileDiffLines> = HashMap::new();
215        while let Some(ref endpoint) = url {
216            let request =
217                Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?;
218            let response =
219                send_api_request(&self.client, request, &self.rate_limit_headers).await?;
220            url = Self::try_next_page(response.headers());
221            let body = response.text().await?;
222            let files_list = if !is_pr {
223                let json_value: serde_structs::PushEventFiles = serde_json::from_str(&body)?;
224                json_value.files
225            } else {
226                serde_json::from_str::<Vec<serde_structs::GithubChangedFile>>(&body)?
227            };
228            for file in files_list {
229                let ext = Path::new(&file.filename).extension().unwrap_or_default();
230                if !file_filter
231                    .extensions
232                    .contains(&ext.to_string_lossy().to_string())
233                {
234                    continue;
235                }
236                if let Some(patch) = file.patch {
237                    let diff = format!(
238                        "diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}\n",
239                        old = file.previous_filename.unwrap_or(file.filename.clone()),
240                        new = file.filename,
241                    );
242                    for (name, info) in parse_diff(&diff, file_filter, lines_changed_only) {
243                        files.entry(name).or_insert(info);
244                    }
245                } else if file.changes == 0 {
246                    // file may have been only renamed.
247                    // include it in case files-changed-only is enabled.
248                    files.entry(file.filename).or_default();
249                }
250                // else changes are too big (per git server limits) or we don't care
251            }
252        }
253        Ok(files)
254    }
255}