git_bot_feedback/client/github/
mod.rs1use 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
26pub struct GithubApiClient {
28 client: Client,
30
31 pull_request: i64,
33
34 pub event_name: String,
36
37 api_url: Url,
39
40 repo: String,
42
43 sha: String,
45
46 pub debug_enabled: bool,
48
49 rate_limit_headers: RestApiRateLimitHeaders,
51}
52
53impl RestApiClient for GithubApiClient {
55 fn start_log_group(name: &str) {
83 log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
84 }
85
86 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 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 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 files.entry(file.filename).or_default();
249 }
250 }
252 }
253 Ok(files)
254 }
255}