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