use http::HeaderMap;
use reqwest::Method;
use serde::Deserialize;
use serde_json::{Value, json};
use url::Url;
use vgi_forge::{ForgeError, Resource, Result};
use crate::api::Auth;
use crate::forge::GitHubForge;
use crate::secret::Secret;
use crate::webhook;
const PERMS_CHECKS: &[(&str, &str)] = &[("checks", "write"), ("metadata", "read")];
const PERMS_READ: &[(&str, &str)] = &[("contents", "read"), ("metadata", "read")];
const PERMS_PULLS: &[(&str, &str)] = &[("metadata", "read"), ("pull_requests", "read")];
const PERMS_METADATA: &[(&str, &str)] = &[("metadata", "read")];
const COMPARE_PER_PAGE: usize = 100;
const COMPARE_PAGES: usize = 3;
const MAX_SUMMARY: usize = 65_000;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CheckTriggerKind {
PullRequest {
number: u64,
},
MergeGroup,
Rerequested {
number: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CheckTrigger {
pub repo: Resource,
pub repo_id: u64,
pub head_sha: String,
pub base_sha: String,
pub base_ref: String,
pub kind: CheckTriggerKind,
pub delivery_id: Option<String>,
pub head_ref: Option<String>,
pub author_login: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PullRequestInfo {
pub head_sha: String,
pub base_ref: String,
pub base_sha: String,
pub open: bool,
pub head_ref: String,
pub head_repo_id: Option<u64>,
pub base_repo_id: Option<u64>,
pub author_login: Option<String>,
pub author_id: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CheckConclusion {
Success,
Failure,
}
impl CheckConclusion {
fn as_str(self) -> &'static str {
match self {
CheckConclusion::Success => "success",
CheckConclusion::Failure => "failure",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Comparison {
pub commits: Vec<String>,
pub total: u64,
pub merge_base: String,
}
fn branch_of(r: &str) -> &str {
r.strip_prefix("refs/heads/").unwrap_or(r)
}
fn rerequests(
repo: &Resource,
repo_id: u64,
head_sha: &str,
prs: Option<&Value>,
delivery_id: &Option<String>,
) -> Vec<CheckTrigger> {
let mut out = Vec::new();
for pr in prs.and_then(Value::as_array).into_iter().flatten() {
let Some(number) = pr.get("number").and_then(Value::as_u64) else {
continue;
};
let base_ref = pr
.pointer("/base/ref")
.and_then(Value::as_str)
.unwrap_or_default();
let base_sha = pr
.pointer("/base/sha")
.and_then(Value::as_str)
.unwrap_or_default();
if base_ref.is_empty() || check_sha(base_sha).is_err() {
continue;
}
out.push(CheckTrigger {
repo: repo.clone(),
repo_id,
head_sha: head_sha.to_string(),
base_sha: base_sha.to_string(),
base_ref: branch_of(base_ref).to_string(),
kind: CheckTriggerKind::Rerequested { number },
delivery_id: delivery_id.clone(),
head_ref: None,
author_login: None,
});
}
out
}
impl GitHubForge {
pub fn parse_check_trigger(
&self,
headers: &HeaderMap,
body: &[u8],
) -> Result<Vec<CheckTrigger>> {
webhook::verify_signature(self.webhook_secret(), headers, body)?;
let event = headers
.get("x-github-event")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| ForgeError::Webhook("missing X-GitHub-Event".into()))?;
let delivery_id = headers
.get("x-github-delivery")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if !matches!(
event,
"pull_request" | "merge_group" | "check_run" | "check_suite"
) {
return Ok(Vec::new());
}
let payload: Value = serde_json::from_slice(body)
.map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
let repo_json = &payload["repository"];
let full_name = repo_json
.get("full_name")
.and_then(Value::as_str)
.ok_or_else(|| {
ForgeError::Webhook("payload is missing `repository.full_name`".into())
})?;
let repo = Resource::parse_owner_repo(&format!("{}/{full_name}", self.config().host))?;
let repo_id = repo_json
.get("id")
.and_then(Value::as_u64)
.ok_or_else(|| ForgeError::Webhook("payload is missing `repository.id`".into()))?;
let one = |head_sha: String, base_sha: String, base_ref: &str, kind| {
vec![CheckTrigger {
repo: repo.clone(),
repo_id,
head_sha,
base_sha,
base_ref: branch_of(base_ref).to_string(),
kind,
delivery_id: delivery_id.clone(),
head_ref: None,
author_login: None,
}]
};
let app_id = self.config().app_id;
Ok(match (event, action) {
("pull_request", "opened" | "synchronize" | "reopened" | "edited") => {
if action == "edited" && payload.pointer("/changes/base").is_none() {
return Ok(Vec::new());
}
let pr = &payload["pull_request"];
let number = pr
.get("number")
.and_then(Value::as_u64)
.ok_or_else(|| ForgeError::Webhook("pull request has no number".into()))?;
let mut t = one(
sha_at(pr, &["head", "sha"])?,
sha_at(pr, &["base", "sha"])?,
str_at(pr, &["base", "ref"])?,
CheckTriggerKind::PullRequest { number },
);
t[0].head_ref = str_at(pr, &["head", "ref"])
.ok()
.map(|r| branch_of(r).to_string());
t[0].author_login = str_at(pr, &["user", "login"]).ok().map(str::to_string);
t
}
("merge_group", "checks_requested") => {
let group = &payload["merge_group"];
one(
sha_at(group, &["head_sha"])?,
sha_at(group, &["base_sha"])?,
str_at(group, &["base_ref"])?,
CheckTriggerKind::MergeGroup,
)
}
("check_run", "rerequested") | ("check_suite", "rerequested") => {
let obj = &payload[event];
if obj.pointer("/app/id").and_then(Value::as_u64) != Some(app_id) {
return Ok(Vec::new());
}
let head = sha_at(obj, &["head_sha"])?;
rerequests(
&repo,
repo_id,
&head,
obj.get("pull_requests"),
&delivery_id,
)
}
_ => Vec::new(),
})
}
pub async fn default_branch(&self, repo: &Resource) -> Result<String> {
let (token, owner, name) = self.repo_token_for(repo, PERMS_METADATA).await?;
#[derive(Deserialize)]
struct R {
default_branch: Option<String>,
}
let r: R = self
.api()
.json(
Method::GET,
self.api().url(&["repos", &owner, &name]),
Auth::Bearer(&token),
None,
repo.as_str(),
)
.await?;
r.default_branch
.ok_or_else(|| ForgeError::Protocol(format!("`{repo}` has no default branch")))
}
pub async fn pull_request(&self, repo: &Resource, number: u64) -> Result<PullRequestInfo> {
let (token, owner, name) = self.repo_token_for(repo, PERMS_PULLS).await?;
let pr: Value = self
.api()
.json(
Method::GET,
self.api()
.url(&["repos", &owner, &name, "pulls", &number.to_string()]),
Auth::Bearer(&token),
None,
"pull request",
)
.await?;
Ok(PullRequestInfo {
head_sha: sha_at(&pr, &["head", "sha"])?,
base_ref: branch_of(str_at(&pr, &["base", "ref"])?).to_string(),
base_sha: sha_at(&pr, &["base", "sha"])?,
open: pr.get("state").and_then(Value::as_str) == Some("open"),
head_ref: pr
.pointer("/head/ref")
.and_then(Value::as_str)
.map(|r| branch_of(r).to_string())
.unwrap_or_default(),
head_repo_id: pr.pointer("/head/repo/id").and_then(Value::as_u64),
base_repo_id: pr.pointer("/base/repo/id").and_then(Value::as_u64),
author_login: pr
.pointer("/user/login")
.and_then(Value::as_str)
.map(str::to_string),
author_id: pr.pointer("/user/id").and_then(Value::as_u64),
})
}
pub async fn compare_commits(
&self,
repo: &Resource,
base: &str,
head: &str,
) -> Result<Comparison> {
check_sha(base)?;
check_sha(head)?;
let (token, owner, name) = self.repo_token_for(repo, PERMS_READ).await?;
#[derive(Deserialize)]
struct Sha {
sha: String,
}
#[derive(Deserialize)]
struct Compare {
total_commits: u64,
merge_base_commit: Sha,
#[serde(default)]
commits: Vec<Sha>,
}
let range = format!("{base}...{head}");
let mut commits = Vec::new();
let mut total = 0;
let mut merge_base = String::new();
for page in 1..=COMPARE_PAGES {
let mut url = self.api().url(&["repos", &owner, &name, "compare", &range]);
url.query_pairs_mut()
.append_pair("per_page", &COMPARE_PER_PAGE.to_string())
.append_pair("page", &page.to_string());
let c: Compare = self
.api()
.json(
Method::GET,
url,
Auth::Bearer(&token),
None,
"commit comparison",
)
.await?;
total = c.total_commits;
merge_base = c.merge_base_commit.sha;
let n = c.commits.len();
commits.extend(c.commits.into_iter().map(|s| s.sha));
if n < COMPARE_PER_PAGE || commits.len() as u64 >= total {
break;
}
}
for sha in &commits {
check_sha(sha)?;
}
Ok(Comparison {
commits,
total,
merge_base,
})
}
pub async fn merge_base(&self, repo: &Resource, a: &str, b: &str) -> Result<String> {
check_sha(a)?;
check_sha(b)?;
let (token, owner, name) = self.repo_token_for(repo, PERMS_READ).await?;
#[derive(Deserialize)]
struct Sha {
sha: String,
}
#[derive(Deserialize)]
struct Compare {
merge_base_commit: Sha,
}
let range = format!("{a}...{b}");
let mut url = self.api().url(&["repos", &owner, &name, "compare", &range]);
url.query_pairs_mut().append_pair("per_page", "1");
let c: Compare = self
.api()
.json(Method::GET, url, Auth::Bearer(&token), None, "merge base")
.await?;
check_sha(&c.merge_base_commit.sha)?;
Ok(c.merge_base_commit.sha)
}
pub async fn contents_read_token(&self, repo: &Resource) -> Result<Secret> {
Ok(self.repo_token_for(repo, PERMS_READ).await?.0)
}
pub fn clone_url(&self, repo: &Resource) -> Result<Url> {
repo.require_owner_repo()?;
let name = repo.repo_name().ok_or_else(|| ForgeError::WrongResource {
resource: repo.to_string(),
expected: "a repository".into(),
})?;
Ok(self.api().web_url(&[repo.owner(), &format!("{name}.git")]))
}
pub async fn start_check_run(
&self,
repo: &Resource,
head_sha: &str,
name: &str,
external_id: &str,
) -> Result<u64> {
check_sha(head_sha)?;
let (token, owner, repo_name) = self.repo_token_for(repo, PERMS_CHECKS).await?;
#[derive(Deserialize)]
struct Created {
id: u64,
}
let body = json!({
"name": name,
"head_sha": head_sha,
"status": "in_progress",
"external_id": external_id,
"output": {
"title": "Checking commit signatures",
"summary": "The community's bridge is verifying every commit against the Trust Registry.",
},
});
let created: Created = self
.api()
.json(
Method::POST,
self.api().url(&["repos", &owner, &repo_name, "check-runs"]),
Auth::Bearer(&token),
Some(&body),
"check run",
)
.await?;
Ok(created.id)
}
pub async fn finish_check_run(
&self,
repo: &Resource,
id: u64,
conclusion: CheckConclusion,
title: &str,
summary: &str,
) -> Result<()> {
let (token, owner, repo_name) = self.repo_token_for(repo, PERMS_CHECKS).await?;
let body = json!({
"status": "completed",
"conclusion": conclusion.as_str(),
"output": { "title": title, "summary": truncate(summary, MAX_SUMMARY) },
});
self.api()
.send(
Method::PATCH,
self.api()
.url(&["repos", &owner, &repo_name, "check-runs", &id.to_string()]),
Auth::Bearer(&token),
Some(&body),
"check run",
)
.await?;
Ok(())
}
}
pub fn check_sha(sha: &str) -> Result<()> {
let ok = (sha.len() == 40 || sha.len() == 64)
&& sha
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if ok {
Ok(())
} else {
Err(ForgeError::Protocol(format!("`{sha}` is not a commit id")))
}
}
fn sha_at(v: &Value, path: &[&str]) -> Result<String> {
let s = path
.iter()
.try_fold(v, |v, k| v.get(k))
.and_then(Value::as_str)
.ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))?;
check_sha(s)
.map_err(|_| ForgeError::Webhook(format!("`{}` is not a commit id", path.join("."))))?;
Ok(s.to_string())
}
fn str_at<'a>(v: &'a Value, path: &[&str]) -> Result<&'a str> {
path.iter()
.try_fold(v, |v, k| v.get(k))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}\n\n… (truncated)", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shas_are_hex_of_the_right_length() {
assert!(check_sha(&"a".repeat(40)).is_ok());
assert!(check_sha(&"0".repeat(64)).is_ok());
for bad in [
"",
"abc",
&"A".repeat(40),
&"g".repeat(40),
"--upload-pack=x",
] {
assert!(check_sha(bad).is_err(), "{bad}");
}
}
#[test]
fn long_summaries_are_cut_on_a_char_boundary() {
let s = "é".repeat(40_000);
let t = truncate(&s, MAX_SUMMARY);
assert!(t.len() < s.len());
assert!(t.ends_with("(truncated)"));
}
}