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
59fn step_summary_url() -> Option<String> {
60 let server_url = env::var("GITHUB_SERVER_URL").ok()?;
61 let repo = env::var("GITHUB_REPOSITORY").ok()?;
62 let run_id = env::var("GITHUB_RUN_ID").ok()?;
63 Some(format!(
64 "{}/{repo}/actions/runs/{run_id}",
65 server_url.trim_end_matches('/')
66 ))
67}
68
69#[async_trait]
71impl RestApiClient for GithubApiClient {
72 fn client_kind(&self) -> String {
73 "github".to_string()
74 }
75
76 fn start_log_group(&self, name: &str) {
104 log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
105 }
106
107 fn end_log_group(&self, _name: &str) {
112 log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
113 }
114
115 fn event_name(&self) -> Option<String> {
116 Some(self.event_name.clone())
117 }
118
119 fn is_debug_enabled(&self) -> bool {
120 self.debug_enabled
121 }
122
123 fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError> {
124 self.client = Client::builder()
125 .default_headers(Self::make_headers()?)
126 .user_agent(user_agent)
127 .build()?;
128 Ok(())
129 }
130
131 async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
132 env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
133 let comments_url = match &self.pull_request {
134 Some(pr_event) => {
135 if pr_event.locked {
136 return Ok(()); }
138 self.api_url.join(
139 format!("repos/{}/issues/{}/comments", self.repo, pr_event.number).as_str(),
140 )?
141 }
142 None => self
143 .api_url
144 .join(format!("repos/{}/commits/{}/comments", self.repo, self.sha).as_str())?,
145 };
146 self.update_comment(comments_url, options).await
147 }
148
149 #[inline]
150 fn is_pr_event(&self) -> bool {
151 self.pull_request.is_some()
152 }
153
154 fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
155 let gh_out = env::var("GITHUB_STEP_SUMMARY")
156 .map_err(|e| ClientError::env_var("GITHUB_STEP_SUMMARY", e))?;
157 match OpenOptions::new().append(true).open(gh_out) {
159 Ok(mut gh_out_file) => {
160 writeln!(&mut gh_out_file, "\n{comment}\n")
161 .map_err(|e| ClientError::io("write to GITHUB_STEP_SUMMARY file", e))?;
162 if let Some(step_summary_url) = step_summary_url() {
163 log::info!("View step summary at {step_summary_url}");
164 }
165 Ok(())
166 }
167 Err(e) => Err(ClientError::io("open GITHUB_STEP_SUMMARY file", e)),
168 }
169 }
170
171 fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
172 if vars.is_empty() {
173 return Ok(());
176 }
177 let gh_out =
178 env::var("GITHUB_OUTPUT").map_err(|e| ClientError::env_var("GITHUB_OUTPUT", e))?;
179 match OpenOptions::new().append(true).open(gh_out) {
180 Ok(mut gh_out_file) => {
181 for out_var in vars {
182 out_var.validate()?;
183 writeln!(&mut gh_out_file, "{out_var}")
184 .map_err(|e| ClientError::io("write to GITHUB_OUTPUT file", e))?;
185 }
186 Ok(())
187 }
188 Err(e) => Err(ClientError::io("open GITHUB_OUTPUT file", e)),
189 }
190 }
191
192 fn write_file_annotations(&self, annotations: &[FileAnnotation]) -> Result<(), ClientError> {
193 if annotations.is_empty() {
194 return Ok(());
197 }
198 let stdout = io::stdout();
199 let mut handle = stdout.lock();
200 for annotation in annotations {
201 writeln!(&mut handle, "{}", annotation.fmt_github())
202 .map_err(|e| ClientError::io("write to file annotation to stdout", e))?;
203 }
204 handle
205 .flush()
206 .map_err(|e| ClientError::io("flush stdout with file annotations", e))?;
207 Ok(())
208 }
209
210 #[cfg(feature = "file-changes")]
211 #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
212 async fn get_list_of_changed_files(
213 &self,
214 file_filter: &FileFilter,
215 lines_changed_only: &LinesChangedOnly,
216 _base_diff: Option<String>,
217 _ignore_index: bool,
218 ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
219 use crate::client::github::serde_structs::GitHubChangedFileStatus;
220
221 let (url, is_pr) = match &self.pull_request {
222 Some(pr_event) => (
223 self.api_url.join(
224 format!("repos/{}/pulls/{}/files", self.repo, pr_event.number).as_str(),
225 )?,
226 true,
227 ),
228 None => (
229 self.api_url
230 .join(format!("repos/{}/commits/{}", self.repo, self.sha).as_str())?,
231 false,
232 ),
233 };
234 let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
235 let mut files: HashMap<String, FileDiffLines> = HashMap::new();
236 while let Some(endpoint) = url.take() {
237 let request = self.make_api_request(&self.client, endpoint, Method::GET, None, None)?;
238 let response = self
239 .send_api_request(&self.client, request, &self.rate_limit_headers)
240 .await
241 .map_err(|e| e.add_request_context("get list of changed files"))?;
242 url = self.try_next_page(response.headers());
243 if let Err(e) = response.error_for_status_ref() {
244 if let Ok(body) = response.text().await {
245 log::error!("Failed to get list of changed files: {e:?}\n{body}");
246 }
247 return Err(
248 ClientError::Request(e).add_request_context("get list of changed files")
249 );
250 }
251 let body = response.text().await?;
252 let files_list = if !is_pr {
253 let json_value: serde_structs::PushEventFiles = serde_json::from_str(&body)
254 .map_err(|e| ClientError::json("deserialize list of changed files", e))?;
255 json_value.files
256 } else {
257 serde_json::from_str::<Vec<serde_structs::GithubChangedFile>>(&body)
258 .map_err(|e| ClientError::json("deserialize list of changed files", e))?
259 };
260 for file in files_list {
261 let ext = Path::new(&file.filename).extension().unwrap_or_default();
262 if !file_filter
263 .extensions
264 .contains(&ext.to_string_lossy().to_string())
265 || matches!(file.status, GitHubChangedFileStatus::Removed)
267 {
268 continue;
269 }
270 if let Some(patch) = file.patch {
271 let diff = format!(
272 "diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}\n",
273 old = file.previous_filename.unwrap_or(file.filename.clone()),
274 new = file.filename,
275 );
276 for (name, info) in parse_diff(&diff, file_filter, lines_changed_only)? {
277 files.entry(name).or_insert(info);
278 }
279 } else if file.changes == 0 || matches!(lines_changed_only, LinesChangedOnly::Off) {
280 files.entry(file.filename).or_default();
283 } else {
284 log::warn!(
285 "File {} has no patch info about {} changes, skipping",
286 file.filename,
287 file.changes
288 );
289 }
290 }
292 }
293
294 Ok(files)
295 }
296
297 async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
298 if let Some(pr_info) = self.pull_request.as_ref() {
299 if pr_info.locked
300 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
301 {
302 return Ok(());
303 }
304 env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
305
306 let keep_reviews = self.check_reused_comments(options).await?;
309 let url = self
311 .api_url
312 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
313 self.hide_outdated_reviews(url, keep_reviews, &options.marker)
314 .await?;
315 }
316 Ok(())
317 }
318
319 async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
320 if let Some(pr_info) = self.pull_request.as_ref() {
321 if (!options.allow_draft && pr_info.draft)
322 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
323 || pr_info.locked
324 {
325 return Ok(());
326 }
327 env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
328 let url = self
329 .api_url
330 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
331 let payload = FullReview {
332 event: match options.action {
333 ReviewAction::Comment => String::from("COMMENT"),
334 ReviewAction::Approve => String::from("APPROVE"),
335 ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
336 },
337 body: if options.summary.starts_with(&options.marker) {
338 options.summary.clone()
339 } else {
340 format!("{}{}", options.marker, options.summary)
341 },
342 comments: options
343 .comments
344 .iter()
345 .map(ReviewDiffComment::from)
346 .map(|mut r| {
347 if !r.body.starts_with(&options.marker) {
348 r.body = format!("{}{}", options.marker, r.body);
349 }
350 r
351 })
352 .collect(),
353 };
354 let request = self.make_api_request(
355 &self.client,
356 url,
357 Method::POST,
358 Some(
359 serde_json::to_string(&payload)
360 .map_err(|e| ClientError::json("serialize PR review payload", e))?,
361 ),
362 None,
363 )?;
364 let response = self
365 .send_api_request(&self.client, request, &self.rate_limit_headers)
366 .await;
367 match response {
368 Ok(response) => {
369 self.log_response(response, "Failed to post PR review")
370 .await;
371 }
372 Err(e) => {
373 return Err(e.add_request_context("post PR review"));
374 }
375 }
376 }
377 Ok(())
378 }
379}