use std::{
env,
fs::OpenOptions,
io::{self, Write},
};
use async_trait::async_trait;
use reqwest::{Client, Method, Url};
use crate::{
FileAnnotation, OutputVariable, ReviewAction, ReviewOptions, ThreadCommentOptions,
client::{ClientError, RestApiClient, RestApiRateLimitHeaders},
};
mod graphql;
mod serde_structs;
use serde_structs::{FullReview, PullRequestInfo, PullRequestState, ReviewDiffComment};
mod specific_api;
#[cfg(feature = "file-changes")]
use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
#[cfg(feature = "file-changes")]
use std::{collections::HashMap, path::Path};
pub struct GithubApiClient {
client: Client,
pull_request: Option<PullRequestInfo>,
pub event_name: String,
api_url: Url,
repo: String,
sha: String,
pub debug_enabled: bool,
rate_limit_headers: RestApiRateLimitHeaders,
}
#[async_trait]
impl RestApiClient for GithubApiClient {
fn start_log_group(&self, name: &str) {
log::info!(target: "CI_LOG_GROUPING", "::group::{name}");
}
fn end_log_group(&self, _name: &str) {
log::info!(target: "CI_LOG_GROUPING", "::endgroup::");
}
fn event_name(&self) -> Option<String> {
Some(self.event_name.clone())
}
fn is_debug_enabled(&self) -> bool {
self.debug_enabled
}
fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError> {
self.client = Client::builder()
.default_headers(Self::make_headers()?)
.user_agent(user_agent)
.build()?;
Ok(())
}
async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError> {
env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
let comments_url = match &self.pull_request {
Some(pr_event) => {
if pr_event.locked {
return Ok(()); }
self.api_url.join(
format!("repos/{}/issues/{}/comments", self.repo, pr_event.number).as_str(),
)?
}
None => self
.api_url
.join(format!("repos/{}/commits/{}/comments", self.repo, self.sha).as_str())?,
};
self.update_comment(comments_url, options).await
}
#[inline]
fn is_pr_event(&self) -> bool {
self.pull_request.is_some()
}
fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
let gh_out = env::var("GITHUB_STEP_SUMMARY")
.map_err(|e| ClientError::env_var("GITHUB_STEP_SUMMARY", e))?;
match OpenOptions::new().append(true).open(gh_out) {
Ok(mut gh_out_file) => writeln!(&mut gh_out_file, "\n{comment}\n")
.map_err(|e| ClientError::io("write to GITHUB_STEP_SUMMARY file", e)),
Err(e) => Err(ClientError::io("open GITHUB_STEP_SUMMARY file", e)),
}
}
fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError> {
if vars.is_empty() {
return Ok(());
}
let gh_out =
env::var("GITHUB_OUTPUT").map_err(|e| ClientError::env_var("GITHUB_OUTPUT", e))?;
match OpenOptions::new().append(true).open(gh_out) {
Ok(mut gh_out_file) => {
for out_var in vars {
out_var.validate()?;
writeln!(&mut gh_out_file, "{out_var}")
.map_err(|e| ClientError::io("write to GITHUB_OUTPUT file", e))?;
}
Ok(())
}
Err(e) => Err(ClientError::io("open GITHUB_OUTPUT file", e)),
}
}
fn write_file_annotations(&self, annotations: &[FileAnnotation]) -> Result<(), ClientError> {
if annotations.is_empty() {
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
for annotation in annotations {
writeln!(&mut handle, "{}", annotation.fmt_github())
.map_err(|e| ClientError::io("write to file annotation to stdout", e))?;
}
handle
.flush()
.map_err(|e| ClientError::io("flush stdout with file annotations", e))?;
Ok(())
}
#[cfg(feature = "file-changes")]
#[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
async fn get_list_of_changed_files(
&self,
file_filter: &FileFilter,
lines_changed_only: &LinesChangedOnly,
_base_diff: Option<String>,
_ignore_index: bool,
) -> Result<HashMap<String, FileDiffLines>, ClientError> {
let (url, is_pr) = match &self.pull_request {
Some(pr_event) => (
self.api_url.join(
format!("repos/{}/pulls/{}/files", self.repo, pr_event.number).as_str(),
)?,
true,
),
None => (
self.api_url
.join(format!("repos/{}/commits/{}", self.repo, self.sha).as_str())?,
false,
),
};
let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
let mut files: HashMap<String, FileDiffLines> = HashMap::new();
while let Some(ref endpoint) = url {
let request =
self.make_api_request(&self.client, endpoint.to_owned(), Method::GET, None, None)?;
let response = self
.send_api_request(&self.client, request, &self.rate_limit_headers)
.await
.map_err(|e| e.add_request_context("get list of changed files"))?;
url = self.try_next_page(response.headers());
let body = response.text().await?;
let files_list = if !is_pr {
let json_value: serde_structs::PushEventFiles = serde_json::from_str(&body)
.map_err(|e| ClientError::json("deserialize list of changed files", e))?;
json_value.files
} else {
serde_json::from_str::<Vec<serde_structs::GithubChangedFile>>(&body)
.map_err(|e| ClientError::json("deserialize list of changed files", e))?
};
for file in files_list {
let ext = Path::new(&file.filename).extension().unwrap_or_default();
if !file_filter
.extensions
.contains(&ext.to_string_lossy().to_string())
{
continue;
}
if let Some(patch) = file.patch {
let diff = format!(
"diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}\n",
old = file.previous_filename.unwrap_or(file.filename.clone()),
new = file.filename,
);
for (name, info) in parse_diff(&diff, file_filter, lines_changed_only)? {
files.entry(name).or_insert(info);
}
} else if file.changes == 0 {
files.entry(file.filename).or_default();
}
}
}
Ok(files)
}
async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError> {
if let Some(pr_info) = self.pull_request.as_ref() {
if pr_info.locked
|| (!options.allow_closed && pr_info.state == PullRequestState::Closed)
{
return Ok(());
}
env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
let keep_reviews = self.check_reused_comments(options).await?;
let url = self
.api_url
.join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
self.hide_outdated_reviews(url, keep_reviews, &options.marker)
.await?;
}
Ok(())
}
async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError> {
if let Some(pr_info) = self.pull_request.as_ref() {
if (!options.allow_draft && pr_info.draft)
|| (!options.allow_closed && pr_info.state == PullRequestState::Closed)
|| pr_info.locked
{
return Ok(());
}
env::var("GITHUB_TOKEN").map_err(|e| ClientError::env_var("GITHUB_TOKEN", e))?;
let url = self
.api_url
.join(format!("repos/{}/pulls/{}/reviews", self.repo, pr_info.number).as_str())?;
let payload = FullReview {
event: match options.action {
ReviewAction::Comment => String::from("COMMENT"),
ReviewAction::Approve => String::from("APPROVE"),
ReviewAction::RequestChanges => String::from("REQUEST_CHANGES"),
},
body: format!("{}{}", options.marker, options.summary),
comments: options
.comments
.iter()
.map(ReviewDiffComment::from)
.map(|mut r| {
if !r.body.starts_with(&options.marker) {
r.body = format!("{}{}", options.marker, r.body);
}
r
})
.collect(),
};
let request = self.make_api_request(
&self.client,
url,
Method::POST,
Some(
serde_json::to_string(&payload)
.map_err(|e| ClientError::json("serialize PR review payload", e))?,
),
None,
)?;
let response = self
.send_api_request(&self.client, request, &self.rate_limit_headers)
.await;
match response {
Ok(response) => {
self.log_response(response, "Failed to post PR review")
.await;
}
Err(e) => {
return Err(e.add_request_context("post PR review"));
}
}
}
Ok(())
}
}