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::{ClientError, RestApiClient, RestApiRateLimitHeaders},
18};
19mod graphql;
20mod serde_structs;
21use serde_structs::{FullReview, PullRequestInfo, PullRequestState, ReviewDiffComment};
22mod specific_api;
23
24#[cfg(feature = "file-changes")]
25use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
26#[cfg(feature = "file-changes")]
27use std::{collections::HashMap, path::Path};
28
29pub struct GithubApiClient {
31 client: Client,
33
34 pull_request: Option<PullRequestInfo>,
36
37 pub event_name: String,
39
40 api_url: Url,
42
43 repo: String,
45
46 sha: String,
48
49 pub debug_enabled: bool,
51
52 rate_limit_headers: RestApiRateLimitHeaders,
54}
55
56#[async_trait]
58impl RestApiClient for GithubApiClient {
59 fn start_log_group(&self, name: &str) {
87 log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
88 }
89
90 fn end_log_group(&self, _name: &str) {
95 log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
96 }
97
98 async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
99 env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
100 let comments_url = match &self.pull_request {
101 Some(pr_event) => {
102 if pr_event.locked {
103 return Ok(()); }
105 self.api_url.join(
106 format!("repos/{}/issues/{}/comments", self.repo, pr_event.number).as_str(),
107 )?
108 }
109 None => self
110 .api_url
111 .join(format!("repos/{}/commits/{}/comments", self.repo, self.sha).as_str())?,
112 };
113 self.update_comment(comments_url, options).await
114 }
115
116 #[inline]
117 fn is_pr_event(&self) -> bool {
118 self.pull_request.is_some()
119 }
120
121 fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
122 let gh_out = env::var("GITHUB_STEP_SUMMARY")
123 .map_err(|e| ClientError::env_var("GITHUB_STEP_SUMMARY", e))?;
124 match OpenOptions::new().append(true).open(gh_out) {
126 Ok(mut gh_out_file) => writeln!(&mut gh_out_file, "\n{comment}\n")
127 .map_err(|e| ClientError::io("write to GITHUB_STEP_SUMMARY file", e)),
128 Err(e) => Err(ClientError::io("open GITHUB_STEP_SUMMARY file", e)),
129 }
130 }
131
132 fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
133 if vars.is_empty() {
134 return Ok(());
137 }
138 let gh_out =
139 env::var("GITHUB_OUTPUT").map_err(|e| ClientError::env_var("GITHUB_OUTPUT", e))?;
140 match OpenOptions::new().append(true).open(gh_out) {
141 Ok(mut gh_out_file) => {
142 for out_var in vars {
143 out_var.validate()?;
144 writeln!(&mut gh_out_file, "{out_var}\n")
145 .map_err(|e| ClientError::io("write to GITHUB_OUTPUT file", e))?;
146 }
147 Ok(())
148 }
149 Err(e) => Err(ClientError::io("open GITHUB_OUTPUT file", e)),
150 }
151 }
152
153 fn write_file_annotations(&self, annotations: &[FileAnnotation]) -> Result<(), ClientError> {
154 if annotations.is_empty() {
155 return Ok(());
158 }
159 let stdout = io::stdout();
160 let mut handle = stdout.lock();
161 for annotation in annotations {
162 writeln!(&mut handle, "{annotation}\n")
163 .map_err(|e| ClientError::io("write to file annotation to stdout", e))?;
164 }
165 handle
166 .flush()
167 .map_err(|e| ClientError::io("flush stdout with file annotations", e))?;
168 Ok(())
169 }
170
171 #[cfg(feature = "file-changes")]
172 #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
173 async fn get_list_of_changed_files(
174 &self,
175 file_filter: &FileFilter,
176 lines_changed_only: &LinesChangedOnly,
177 _base_diff: Option<String>,
178 _ignore_index: bool,
179 ) -> Result<HashMap<String, FileDiffLines>, ClientError> {
180 let (url, is_pr) = match &self.pull_request {
181 Some(pr_event) => (
182 self.api_url.join(
183 format!("repos/{}/pulls/{}/files", self.repo, pr_event.number).as_str(),
184 )?,
185 true,
186 ),
187 None => (
188 self.api_url
189 .join(format!("repos/{}/commits/{}", self.repo, self.sha).as_str())?,
190 false,
191 ),
192 };
193 let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
194 let mut files: HashMap<String, FileDiffLines> = HashMap::new();
195 while let Some(ref endpoint) = url {
196 let request =
197 self.make_api_request(&self.client, endpoint.to_owned(), Method::GET, None, None)?;
198 let response = self
199 .send_api_request(&self.client, request, &self.rate_limit_headers)
200 .await
201 .map_err(|e| e.add_request_context("get list of changed files"))?;
202 url = self.try_next_page(response.headers());
203 let body = response.text().await?;
204 let files_list = if !is_pr {
205 let json_value: serde_structs::PushEventFiles = serde_json::from_str(&body)
206 .map_err(|e| ClientError::json("deserialize list of changed files", e))?;
207 json_value.files
208 } else {
209 serde_json::from_str::<Vec<serde_structs::GithubChangedFile>>(&body)
210 .map_err(|e| ClientError::json("deserialize list of changed files", e))?
211 };
212 for file in files_list {
213 let ext = Path::new(&file.filename).extension().unwrap_or_default();
214 if !file_filter
215 .extensions
216 .contains(&ext.to_string_lossy().to_string())
217 {
218 continue;
219 }
220 if let Some(patch) = file.patch {
221 let diff = format!(
222 "diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}\n",
223 old = file.previous_filename.unwrap_or(file.filename.clone()),
224 new = file.filename,
225 );
226 for (name, info) in parse_diff(&diff, file_filter, lines_changed_only) {
227 files.entry(name).or_insert(info);
228 }
229 } else if file.changes == 0 {
230 files.entry(file.filename).or_default();
233 }
234 }
236 }
237 Ok(files)
238 }
239
240 async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
241 if let Some(pr_info) = self.pull_request.as_ref() {
242 if pr_info.locked
243 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
244 {
245 return Ok(());
246 }
247 env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
248
249 let keep_reviews = self.check_reused_comments(options).await?;
252 let url = self
254 .api_url
255 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
256 self.hide_outdated_reviews(url, keep_reviews, &options.marker)
257 .await?;
258 }
259 Ok(())
260 }
261
262 async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
263 if let Some(pr_info) = self.pull_request.as_ref() {
264 if (!options.allow_draft && pr_info.draft)
265 || (!options.allow_closed && pr_info.state == PullRequestState::Closed)
266 || pr_info.locked
267 {
268 return Ok(());
269 }
270 env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
271 let url = self
272 .api_url
273 .join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
274 let payload = FullReview {
275 event: match options.action {
276 ReviewAction::Comment => String::from("COMMENT"),
277 ReviewAction::Approve => String::from("APPROVE"),
278 ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
279 },
280 body: format!("{}{}", options.marker, options.summary),
281 comments: options
282 .comments
283 .iter()
284 .map(ReviewDiffComment::from)
285 .map(|mut r| {
286 if !r.body.starts_with(&options.marker) {
287 r.body = format!("{}{}", options.marker, r.body);
288 }
289 r
290 })
291 .collect(),
292 };
293 let request = self.make_api_request(
294 &self.client,
295 url,
296 Method::POST,
297 Some(
298 serde_json::to_string(&payload)
299 .map_err(|e| ClientError::json("serialize PR review payload", e))?,
300 ),
301 None,
302 )?;
303 let response = self
304 .send_api_request(&self.client, request, &self.rate_limit_headers)
305 .await;
306 match response {
307 Ok(response) => {
308 self.log_response(response, "Failed to post PR review")
309 .await;
310 }
311 Err(e) => {
312 return Err(e.add_request_context("post PR review"));
313 }
314 }
315 }
316 Ok(())
317 }
318}