use anyhow::{bail, Context as _, Result};
use serde::Deserialize;
const COMMENT_MARKER: &str = "<!-- covrs-comment -->";
fn github_request(method: &str, url: &str, token: &str) -> ureq::Request {
ureq::request(method, url)
.set("Authorization", &format!("Bearer {token}"))
.set("Accept", "application/vnd.github+json")
.set("User-Agent", "covrs")
.set("X-GitHub-Api-Version", "2022-11-28")
}
fn check_response(
result: Result<ureq::Response, ureq::Error>,
action: &str,
) -> Result<ureq::Response> {
match result {
Ok(resp) => Ok(resp),
Err(ureq::Error::Status(code, resp)) => {
let body = resp.into_string().unwrap_or_default();
bail!("GitHub API error {action} (HTTP {code}): {body}");
}
Err(e) => bail!("Failed to {action}: {e}"),
}
}
pub struct Context {
token: String,
repo: String,
pr_number: u64,
pub sha: Option<String>,
}
impl Context {
pub fn from_env() -> Result<Self> {
let token = std::env::var("GITHUB_TOKEN")
.context("GITHUB_TOKEN environment variable is required")?;
let repo = std::env::var("GITHUB_REPOSITORY")
.context("GITHUB_REPOSITORY environment variable is required")?;
let pr_number =
pr_number_from_ref().context("could not determine PR number from GITHUB_REF")?;
let sha = std::env::var("GITHUB_SHA").ok();
Ok(Self {
token,
repo,
pr_number,
sha,
})
}
pub fn fetch_diff(&self) -> Result<String> {
eprintln!(
"Fetching diff for {}/pull/{} ...",
self.repo, self.pr_number
);
fetch_pr_diff(&self.token, &self.repo, self.pr_number)
}
pub fn post_comment(&self, body: &str) -> Result<()> {
post_comment(&self.token, &self.repo, self.pr_number, body)?;
eprintln!("Comment posted to {}/pull/{}", self.repo, self.pr_number);
Ok(())
}
}
fn pr_number_from_ref() -> Option<u64> {
let github_ref = std::env::var("GITHUB_REF").ok()?;
let parts: Vec<&str> = github_ref.split('/').collect();
if parts.len() >= 3 && parts[0] == "refs" && parts[1] == "pull" {
parts[2].parse().ok()
} else {
None
}
}
fn fetch_pr_diff(token: &str, repo: &str, pr_number: u64) -> Result<String> {
let url = format!("https://api.github.com/repos/{repo}/pulls/{pr_number}");
let resp = check_response(
github_request("GET", &url, token)
.set("Accept", "application/vnd.github.v3.diff")
.call(),
"fetching PR diff",
)?;
resp.into_string()
.context("Failed to read PR diff response body")
}
#[derive(Deserialize)]
struct Comment {
id: u64,
body: Option<String>,
}
fn find_existing_comment(token: &str, repo: &str, pr_number: u64) -> Result<Option<u64>> {
let mut page = 1u32;
loop {
let url = format!(
"https://api.github.com/repos/{repo}/issues/{pr_number}/comments?per_page=100&page={page}"
);
let resp = github_request("GET", &url, token)
.call()
.context("Failed to list PR comments")?;
let comments: Vec<Comment> = resp.into_json().context("Failed to parse comments JSON")?;
if comments.is_empty() {
break;
}
for c in &comments {
if let Some(ref body) = c.body {
if body.contains(COMMENT_MARKER) {
return Ok(Some(c.id));
}
}
}
page += 1;
}
Ok(None)
}
fn post_comment(token: &str, repo: &str, pr_number: u64, body: &str) -> Result<()> {
let body_with_marker = format!("{COMMENT_MARKER}\n{body}");
match find_existing_comment(token, repo, pr_number)? {
Some(comment_id) => {
let url = format!("https://api.github.com/repos/{repo}/issues/comments/{comment_id}");
let resp = github_request("PATCH", &url, token)
.send_json(serde_json::json!({ "body": body_with_marker }));
check_response(resp, "updating comment")?;
}
None => {
let url = format!("https://api.github.com/repos/{repo}/issues/{pr_number}/comments");
let resp = github_request("POST", &url, token)
.send_json(serde_json::json!({ "body": body_with_marker }));
check_response(resp, "creating comment")?;
}
}
Ok(())
}