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