use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::error::{invalid, Error, Result};
use crate::event::ArtifactId;
use crate::rules::MergePolicy;
use crate::{gh, git, stream};
const CHECKS_PENDING: i32 = 8;
const NO_REQUIRED_CHECKS: &str = "no required checks reported on the ";
fn usable(answer: &gh::Answer) -> bool {
matches!(answer.code, Some(0) | Some(CHECKS_PENDING))
}
fn no_required_checks(answer: &gh::Answer) -> bool {
answer.code == Some(1)
&& answer.stdout.trim().is_empty()
&& answer.stderr.trim_start().starts_with(NO_REQUIRED_CHECKS)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Consult {
Either,
StatusChecks,
Actions,
}
fn consult() -> Result<Consult> {
let Some(raw) = std::env::var_os(gh::CHECK_SOURCE_ENV) else {
return Ok(Consult::Either);
};
match raw.to_string_lossy().trim().to_ascii_lowercase().as_str() {
"" | "auto" => Ok(Consult::Either),
"status-checks" => Ok(Consult::StatusChecks),
"actions" => Ok(Consult::Actions),
other => Err(invalid(format!(
"{} names {other:?}, which is not a check source this build can read: it must be \
\"auto\", \"status-checks\", or \"actions\"",
gh::CHECK_SOURCE_ENV
))),
}
}
const PAGE: u32 = 100;
const CHECK_RUN_REFUSAL: &str = "GraphQL: Resource not accessible by personal access token";
fn check_rollup_refused_for_pat(error: &Error) -> bool {
let Error::Invalid { reason } = error else {
return false;
};
reason.contains(CHECK_RUN_REFUSAL) && reason.contains("statusCheckRollup")
}
pub trait RemoteHost {
fn authenticated_user(&self) -> Result<String>;
fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest>;
fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>>;
fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks>;
fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId>;
fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome>;
}
pub trait Hosting {
fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>>;
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct GitHubHosting;
impl Hosting for GitHubHosting {
fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
Ok(Box::new(GitHub::new(slug)?))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeSpec {
pub head: String,
pub base: String,
pub title: String,
pub body: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeRequest {
pub id: ChangeId,
pub url: Url,
pub head_sha: Sha,
pub base: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ChangeId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Sha(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Check {
pub name: String,
pub status: String,
pub conclusion: Option<String>,
pub required: bool,
}
impl Check {
pub fn settled(&self) -> bool {
self.status.eq_ignore_ascii_case("completed")
}
pub fn green(&self) -> bool {
self.settled()
&& self.conclusion.as_deref().is_some_and(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"success" | "skipped" | "neutral"
)
})
}
pub fn red(&self) -> bool {
self.settled() && !self.green()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CheckSource {
StatusChecks,
Actions,
BranchRules,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeChecks {
pub checks: Vec<Check>,
pub sources: BTreeSet<CheckSource>,
}
impl ChangeChecks {
pub fn complete(&self) -> bool {
self.sources.contains(&CheckSource::StatusChecks)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MergeOutcome {
Merged(Sha),
Queued,
Open,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHub {
repo: String,
}
impl GitHub {
pub fn new(repo: impl Into<String>) -> Result<Self> {
let repo = repo.into();
let mut parts = repo.split('/');
let named = matches!(
(parts.next(), parts.next(), parts.next()),
(Some(owner), Some(name), None)
if !owner.is_empty()
&& !name.is_empty()
&& !repo.starts_with('-')
&& !repo.contains(char::is_whitespace)
);
if !named {
return Err(invalid(format!(
"{repo:?} does not name one repository as owner/name"
)));
}
Ok(Self { repo })
}
fn required_checks(&self, cr: &ChangeRequest) -> Result<BTreeSet<String>> {
addressable(&cr.id.0, "change request id")?;
let answer = gh::attempt(&[
"pr",
"checks",
&cr.id.0,
"--repo",
&self.repo,
"--required",
"--json",
"name",
])?;
if !usable(&answer) {
if no_required_checks(&answer) {
return Ok(BTreeSet::new());
}
return Err(unsaid(&cr.url, &answer.detail()));
}
let value = gh::json(&answer.stdout)?;
let entries = value
.as_array()
.ok_or_else(|| unsaid(&cr.url, &value.to_string()))?;
let mut names = BTreeSet::new();
for entry in entries {
let name = entry
.get("name")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| unsaid(&cr.url, &entry.to_string()))?;
names.insert(name.to_owned());
}
Ok(names)
}
fn job_log(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
addressable(&cr.id.0, "change request id")?;
let answer = gh::attempt(&[
"pr",
"checks",
&cr.id.0,
"--repo",
&self.repo,
"--json",
"name,link",
])?;
if !usable(&answer) {
return Err(invalid(format!(
"gh pr checks would not say where check {name:?} on {} ran: {}",
cr.url,
answer.detail()
)));
}
let value = gh::json(&answer.stdout)?;
let entries = value.as_array().ok_or_else(|| {
invalid(format!(
"gh pr checks returned a non-list of checks on {}, so where check {name:?} ran \
cannot be read from it: {value}",
cr.url
))
})?;
let link = entries
.iter()
.find(|entry| entry.get("name").and_then(|value| value.as_str()) == Some(name))
.and_then(|entry| entry.get("link"))
.and_then(|value| value.as_str())
.ok_or_else(|| {
invalid(format!(
"the host reports no job for check {name:?} on {}",
cr.url
))
})?;
let job = link
.rsplit_once("/job/")
.map(|(_, id)| id)
.filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()))
.ok_or_else(|| {
invalid(format!(
"the host says check {name:?} ran at {link:?}, which names no job this build \
can ask for a log"
))
})?;
gh::invoke(&["run", "view", "--repo", &self.repo, "--log", "--job", job])
}
fn log_of(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
match consult()? {
Consult::StatusChecks => self.job_log(cr, name),
Consult::Actions => self.actions_log(cr, name),
Consult::Either => self.job_log(cr, name).or_else(|reported| {
if !check_rollup_refused_for_pat(&reported) {
return Err(reported);
}
self.actions_log(cr, name).map_err(|actions| {
invalid(format!(
"neither of GitHub's check sources would produce the log of check \
{name:?} on {}: gh pr checks answered {reported}, and the Actions API \
answered {actions}",
cr.url
))
})
}),
}
}
fn rollup_checks(&self, cr: &ChangeRequest) -> Result<Vec<Check>> {
let value = self.view(&cr.id.0, "statusCheckRollup")?;
let reported = value.get("statusCheckRollup").ok_or_else(|| {
invalid(format!(
"gh pr view reported no checks at all on {}",
cr.url
))
})?;
if reported.is_null() {
return Ok(Vec::new());
}
let rollup = reported
.as_array()
.ok_or_else(|| invalid(format!("gh pr view returned a non-list rollup: {reported}")))?;
if rollup.is_empty() {
return Ok(Vec::new());
}
let required = self.required_checks(cr)?;
rollup
.iter()
.map(|entry| check(entry, cr, &required))
.collect()
}
fn actions_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
let jobs = self.actions_jobs(cr)?;
if jobs.is_empty() {
return Ok(ChangeChecks {
checks: Vec::new(),
sources: [CheckSource::Actions].into_iter().collect(),
});
}
let required = self.ruled_checks(cr)?;
Ok(ChangeChecks {
checks: jobs
.into_iter()
.map(|job| Check {
required: required.contains(&job.name),
name: job.name,
status: job.status,
conclusion: job.conclusion,
})
.collect(),
sources: [CheckSource::Actions, CheckSource::BranchRules]
.into_iter()
.collect(),
})
}
fn actions_jobs(&self, cr: &ChangeRequest) -> Result<Vec<Job>> {
let sha = commit(&cr.head_sha)?;
let runs = self.api(&format!(
"repos/{}/actions/runs?head_sha={sha}&per_page={PAGE}",
self.repo
))?;
let mut jobs = Vec::new();
for run in listed(&runs, "workflow_runs", &format!("the commit {sha}"))? {
let id = run
.get("id")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
invalid(format!(
"the Actions API reported a workflow run on {} with no id: {run}",
cr.url
))
})?;
let listing = self.api(&format!(
"repos/{}/actions/runs/{id}/jobs?per_page={PAGE}",
self.repo
))?;
for entry in listed(&listing, "jobs", &format!("workflow run {id}"))? {
jobs.push(job(entry, cr)?);
}
}
Ok(jobs)
}
fn ruled_checks(&self, cr: &ChangeRequest) -> Result<BTreeSet<String>> {
addressable_branch(&cr.base, "the base branch")?;
let value = self.api(&format!(
"repos/{}/rules/branches/{}",
self.repo,
path_segment(&cr.base)
))?;
let rules = value.as_array().ok_or_else(|| {
invalid(format!(
"the rules on {}'s base branch {:?} came back as something that is not a list of \
them, so which of its checks block the merge cannot be read from it: {value}",
cr.url, cr.base
))
})?;
let mut names = BTreeSet::new();
for rule in rules {
if rule.get("type").and_then(|value| value.as_str()) != Some("required_status_checks") {
continue;
}
let required = rule
.get("parameters")
.and_then(|parameters| parameters.get("required_status_checks"))
.and_then(|value| value.as_array())
.ok_or_else(|| unsaid(&cr.url, &rule.to_string()))?;
for entry in required {
let name = entry
.get("context")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| unsaid(&cr.url, &entry.to_string()))?;
names.insert(name.to_owned());
}
}
Ok(names)
}
fn actions_log(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
let job = self
.actions_jobs(cr)?
.into_iter()
.find(|job| job.name == name)
.ok_or_else(|| {
invalid(format!(
"GitHub Actions reports no job named {name:?} on the head commit of {}",
cr.url
))
})?;
gh::invoke(&[
"api",
&format!("repos/{}/actions/jobs/{}/logs", self.repo, job.id),
])
}
fn api(&self, path: &str) -> Result<serde_json::Value> {
gh::json(&gh::invoke(&["api", path])?)
}
fn view(&self, id: &str, fields: &str) -> Result<serde_json::Value> {
addressable(id, "change request id")?;
let raw = gh::invoke(&["pr", "view", id, "--repo", &self.repo, "--json", fields])?;
gh::json(&raw)
}
}
fn addressable(value: &str, what: &str) -> Result<()> {
if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
return Err(invalid(format!(
"{what} {value:?} cannot address anything on the host: it must be non-empty, must \
not begin with '-', and must carry no whitespace"
)));
}
Ok(())
}
fn path_segment(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn commit(sha: &Sha) -> Result<&str> {
if sha.0.is_empty() || !sha.0.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(invalid(format!(
"{:?} is not a commit hash, so the checks reported against it cannot be asked for",
sha.0
)));
}
Ok(&sha.0)
}
struct Job {
id: u64,
name: String,
status: String,
conclusion: Option<String>,
}
fn job(entry: &serde_json::Value, cr: &ChangeRequest) -> Result<Job> {
let field = |name: &str| -> Result<&str> {
entry
.get(name)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
invalid(format!(
"the Actions API returned a job on {} with no {name}: {entry}",
cr.url
))
})
};
Ok(Job {
id: entry
.get("id")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
invalid(format!(
"the Actions API returned a job on {} with no id, so its log cannot be asked \
for: {entry}",
cr.url
))
})?,
name: field("name")?.to_owned(),
status: field("status")?.to_ascii_lowercase(),
conclusion: entry
.get("conclusion")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase),
})
}
fn listed<'a>(
value: &'a serde_json::Value,
field: &str,
what: &str,
) -> Result<&'a Vec<serde_json::Value>> {
let entries = value
.get(field)
.and_then(|value| value.as_array())
.ok_or_else(|| {
invalid(format!(
"the Actions API answered about {what} with something that lists no {field}: \
{value}"
))
})?;
let total = value
.get("total_count")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
invalid(format!(
"the Actions API did not say how many {field} there are on {what}: {value}"
))
})?;
if total > entries.len() as u64 {
return Err(invalid(format!(
"the Actions API reports {total} {field} on {what} and returned {}, so this build has \
not been shown all of them",
entries.len()
)));
}
Ok(entries)
}
fn unreadable(repo: &str, cr: &ChangeRequest, rollup: &Error, actions: &Error) -> Error {
invalid(format!(
"neither of GitHub's check sources could be read for {}, so what its checks say is \
unknown rather than empty. Its check rollup answered: {rollup}. Its Actions API \
answered: {actions}. Grant this credential `Actions: Read` on {repo} to read the \
repository's workflow checks — a fine-grained personal access token cannot read the \
rollup at all, whatever it is scoped to, because GitHub offers no Checks permission for \
one.",
cr.url
))
}
fn addressable_branch(value: &str, what: &str) -> Result<()> {
if !git::is_valid_branch_name(value) {
return Err(invalid(format!(
"{what} {value:?} is a name git would not accept"
)));
}
Ok(())
}
impl RemoteHost for GitHub {
fn authenticated_user(&self) -> Result<String> {
let login = gh::invoke(&["api", "user", "--jq", ".login"])?
.trim()
.to_owned();
if login.is_empty() {
return Err(Error::Invalid {
reason: "gh reported no authenticated user".to_owned(),
});
}
Ok(login)
}
fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
addressable_branch(&req.head, "the head branch")?;
addressable_branch(&req.base, "the base branch")?;
let body = req.body.unwrap_or_default();
let raw = gh::invoke(&[
"pr", "create", "--repo", &self.repo, "--head", &req.head, "--base", &req.base,
"--title", &req.title, "--body", &body,
])?;
let url = raw
.lines()
.map(str::trim)
.rfind(|line| line.starts_with("http"))
.ok_or_else(|| invalid(format!("gh pr create printed no URL: {raw:?}")))?;
let parsed = Url::parse(url)
.map_err(|e| invalid(format!("gh pr create printed {url:?}, not a URL: {e}")))?;
let id = parsed
.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
.ok_or_else(|| {
invalid(format!(
"gh pr create printed {url:?}, which names no change"
))
})?
.to_owned();
Ok(ChangeRequest {
head_sha: head_sha(&self.view(&id, "headRefOid")?)?,
id: ChangeId(id),
url: parsed,
base: req.base,
})
}
fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
addressable_branch(head, "the head branch")?;
addressable_branch(base, "the base branch")?;
let raw = gh::invoke(&[
"pr",
"list",
"--repo",
&self.repo,
"--head",
head,
"--base",
base,
"--state",
"open",
"--json",
"number,url,state,headRefOid",
])?;
let value = gh::json(&raw)?;
let items = value
.as_array()
.ok_or_else(|| invalid(format!("gh pr list returned {raw:?}, not a list")))?;
let mut changes = Vec::new();
for item in items {
let url = item.get("url").and_then(|v| v.as_str()).unwrap_or_default();
let parsed = Url::parse(url)
.map_err(|e| invalid(format!("gh pr list returned {url:?}, not a URL: {e}")))?;
let number = item
.get("number")
.and_then(|value| value.as_u64())
.ok_or_else(|| invalid(format!("gh pr list returned no number: {raw:?}")))?
.to_string();
changes.push(ChangeRequest {
id: ChangeId(number),
url: parsed,
head_sha: head_sha(item)?,
base: base.to_owned(),
});
}
Ok(changes)
}
fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
let rollup = |checks| ChangeChecks {
checks,
sources: [CheckSource::StatusChecks].into_iter().collect(),
};
match consult()? {
Consult::StatusChecks => self.rollup_checks(cr).map(rollup),
Consult::Actions => self.actions_checks(cr),
Consult::Either => match self.rollup_checks(cr) {
Ok(checks) => Ok(rollup(checks)),
Err(refused) if check_rollup_refused_for_pat(&refused) => self
.actions_checks(cr)
.map_err(|actions| unreadable(&self.repo, cr, &refused, &actions)),
Err(refused) => Err(refused),
},
}
}
fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
let log = addressable(&check.name, "check name")
.and_then(|()| self.log_of(cr, &check.name))
.unwrap_or_else(|error| {
format!(
"the host could not produce a log for check {:?} on {}: {error}\n",
check.name, cr.url
)
});
Ok(stream::store_artifact("log", &log)?.id)
}
fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
match policy {
MergePolicy::LocalDirect | MergePolicy::ChangeOpen => Ok(MergeOutcome::Open),
MergePolicy::ChangeAuto => {
addressable(&cr.id.0, "change request id")?;
gh::invoke(&[
"pr", "merge", &cr.id.0, "--repo", &self.repo, "--squash", "--auto",
])?;
let view = self.view(&cr.id.0, MERGE_FIELDS)?;
Ok(match merged_sha(&view, cr)? {
Some(sha) => MergeOutcome::Merged(sha),
None => MergeOutcome::Queued,
})
}
MergePolicy::ChangeDirect => {
addressable(&cr.id.0, "change request id")?;
gh::invoke(&["pr", "merge", &cr.id.0, "--repo", &self.repo, "--squash"])?;
let view = self.view(&cr.id.0, MERGE_FIELDS)?;
match merged_sha(&view, cr)? {
Some(sha) => Ok(MergeOutcome::Merged(sha)),
None => Err(Error::GateFailed {
reason: format!(
"the host accepted the merge of {} but reports it unmerged",
cr.url
),
}),
}
}
}
}
}
fn unsaid(url: &Url, detail: &str) -> Error {
invalid(format!(
"gh pr checks --required answered about {url} with {detail}, so a check it reported does \
not say whether it blocks the merge"
))
}
fn check(
entry: &serde_json::Value,
cr: &ChangeRequest,
required: &BTreeSet<String>,
) -> Result<Check> {
let field = |name: &str| -> Result<&str> {
entry
.get(name)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
invalid(format!(
"gh pr view returned a check on {} with no {name}: {entry}",
cr.url
))
})
};
let name = field("name").or_else(|_| field("context"))?.to_owned();
let blocks = required.contains(&name);
Ok(Check {
name,
status: field("status")?.to_ascii_lowercase(),
conclusion: entry
.get("conclusion")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase),
required: blocks,
})
}
const MERGE_FIELDS: &str = "state,mergeCommit";
fn merged_sha(view: &serde_json::Value, cr: &ChangeRequest) -> Result<Option<Sha>> {
let state = view
.get("state")
.and_then(|value| value.as_str())
.ok_or_else(|| invalid(format!("gh pr view returned no state for {}", cr.url)))?;
if !state.eq_ignore_ascii_case("merged") {
return Ok(None);
}
view.get("mergeCommit")
.and_then(|commit| commit.get("oid"))
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(|sha| Some(Sha(sha.to_owned())))
.ok_or_else(|| {
invalid(format!(
"gh pr view reports {} merged without naming the commit it merged as",
cr.url
))
})
}
fn head_sha(view: &serde_json::Value) -> Result<Sha> {
view.get("headRefOid")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(|sha| Sha(sha.to_owned()))
.ok_or_else(|| invalid(format!("gh returned a change request with no head: {view}")))
}