git_bot_feedback/client/github/
mod.rs1use 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
32pub struct GithubApiClient {
34 client: Client,
36
37 pull_request: Option<PullRequestInfo>,
39
40 pub event_name: String,
42
43 api_url: Url,
45
46 repo: String,
48
49 sha: String,
51
52 pub debug_enabled: bool,
54
55 rate_limit_headers: RestApiRateLimitHeaders,
57}
58
59#[async_trait]
61impl RestApiClient for GithubApiClient {
62 fn client_kind(&self) -> String {
63 "github".to_string()
64 }
65
66 fn start_log_group(&self, name: &str) {
94 log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
95 }
96
97 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(()); }
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 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 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 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 use crate::client::github::serde_structs::GitHubChangedFileStatus;
204
205 let (url, is_pr) = match &self.pull_request {
206 Some(pr_event) => (
207 self.api_url.join(
208 format!("repos/{}/pulls/{}/files", self.repo, pr_event.number).as_str(),
209 )?,
210 true,
211 ),
212 None => (
213 self.api_url
214 .join(format!("repos/{}/commits/{}", self.repo, self.sha).as_str())?,
215 false,
216 ),
217 };
218 let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
219 let mut files: HashMap<String, FileDiffLines> = HashMap::new();
220 while let Some(endpoint) = url.take() {
221 let request = self.make_api_request(&self.client, endpoint, Method::GET, None, None)?;
222 let response = self
223 .send_api_request(&self.client, request, &self.rate_limit_headers)
224 .await
225 .map_err(|e| e.add_request_context("get list of changed files"))?;
226 url = self.try_next_page(response.headers());
227 if let Err(e) = response.error_for_status_ref() {
228 if let Ok(body) = response.text().await {
229 log::error!("Failed to get list of changed files: {e:?}\n{body}");
230 }
231 return Err(
232 ClientError::Request(e).add_request_context("get list of changed files")
233 );
234 }
235 let body = response.text().await?;
236 let files_list = if !is_pr {
237 let json_value: serde_structs::PushEventFiles = serde_json::from_str(&body)
238 .map_err(|e| ClientError::json("deserialize list of changed files", e))?;
239 json_value.files
240 } else {
241 serde_json::from_str::<Vec<serde_structs::GithubChangedFile>>(&body)
242 .map_err(|e| ClientError::json("deserialize list of changed files", e))?
243 };
244 for file in files_list {
245 let ext = Path::new(&file.filename).extension().unwrap_or_default();
246 if !file_filter
247 .extensions
248 .contains(&ext.to_string_lossy().to_string())
249 || matches!(file.status, GitHubChangedFileStatus::Removed)
251 {
252 continue;
253 }
254 if let Some(patch) = file.patch {
255 let diff = format!(
256 "diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}\n",
257 old = file.previous_filename.unwrap_or(file.filename.clone()),
258 new = file.filename,
259 );
260 for (name, info) in parse_diff(&diff, file_filter, lines_changed_only)? {
261 files.entry(name).or_insert(info);
262 }
263 } else if file.changes == 0 || matches!(lines_changed_only, LinesChangedOnly::Off) {
264 files.entry(file.filename).or_default();
267 } else {
268 log::warn!(
269 "File {} has no patch info about {} changes, skipping",
270 file.filename,
271 file.changes
272 );
273 }
274 }
276 }
277 Ok(files)
278 }
279
280 async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
281 if let Some(pr_info) = self.pull_request.as_ref() {
282 if pr_info.locked
283 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
284 {
285 return Ok(());
286 }
287 env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
288
289 let keep_reviews = self.check_reused_comments(options).await?;
292 let url = self
294 .api_url
295 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
296 self.hide_outdated_reviews(url, keep_reviews, &options.marker)
297 .await?;
298 }
299 Ok(())
300 }
301
302 async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
303 if let Some(pr_info) = self.pull_request.as_ref() {
304 if (!options.allow_draft && pr_info.draft)
305 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
306 || pr_info.locked
307 {
308 return Ok(());
309 }
310 env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
311 let url = self
312 .api_url
313 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
314 let payload = FullReview {
315 event: match options.action {
316 ReviewAction::Comment => String::from("COMMENT"),
317 ReviewAction::Approve => String::from("APPROVE"),
318 ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
319 },
320 body: format!("{}{}", options.marker, options.summary),
321 comments: options
322 .comments
323 .iter()
324 .map(ReviewDiffComment::from)
325 .map(|mut r| {
326 if !r.body.starts_with(&options.marker) {
327 r.body = format!("{}{}", options.marker, r.body);
328 }
329 r
330 })
331 .collect(),
332 };
333 let request = self.make_api_request(
334 &self.client,
335 url,
336 Method::POST,
337 Some(
338 serde_json::to_string(&payload)
339 .map_err(|e| ClientError::json("serialize PR review payload", e))?,
340 ),
341 None,
342 )?;
343 let response = self
344 .send_api_request(&self.client, request, &self.rate_limit_headers)
345 .await;
346 match response {
347 Ok(response) => {
348 self.log_response(response, "Failed to post PR review")
349 .await;
350 }
351 Err(e) => {
352 return Err(e.add_request_context("post PR review"));
353 }
354 }
355 }
356 Ok(())
357 }
358}