use serde::Deserialize;
use super::rollup::{RollupEntry, deciding_per_check};
use super::{GhRunner, argv};
#[derive(Debug, Deserialize)]
struct BranchView {
#[serde(default)]
protection: Option<Protection>,
}
#[derive(Debug, Deserialize)]
struct Protection {
#[serde(default)]
enabled: bool,
#[serde(default)]
required_status_checks: Option<RequiredChecks>,
}
#[derive(Debug, Deserialize)]
struct RequiredChecks {
#[serde(default)]
enforcement_level: Option<String>,
#[serde(default)]
contexts: Vec<String>,
#[serde(default)]
checks: Vec<RequiredCheck>,
}
#[derive(Debug, Deserialize)]
struct RequiredCheck {
#[serde(default)]
context: String,
}
#[derive(Debug, Deserialize)]
struct Rule {
#[serde(default, rename = "type")]
kind: String,
#[serde(default)]
parameters: Option<RuleParameters>,
}
#[derive(Debug, Deserialize)]
struct RuleParameters {
#[serde(default)]
required_status_checks: Vec<RequiredCheck>,
}
fn push_unique(names: &mut Vec<String>, name: &str) {
let name = name.trim();
if !name.is_empty() && !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
pub(crate) fn parse_required(json: &str) -> anyhow::Result<Option<Vec<String>>> {
let view: BranchView = serde_json::from_str(json)
.map_err(|e| anyhow::anyhow!("cannot parse the branch payload: {e}"))?;
let Some(protection) = view.protection else {
return Ok(None);
};
let Some(rsc) = protection.required_status_checks.filter(|r| {
protection.enabled
&& !r
.enforcement_level
.as_deref()
.is_some_and(|l| l.eq_ignore_ascii_case("off"))
}) else {
return Ok(Some(Vec::new()));
};
let mut names: Vec<String> = Vec::new();
for name in rsc
.contexts
.iter()
.chain(rsc.checks.iter().map(|c| &c.context))
{
push_unique(&mut names, name);
}
Ok(Some(names))
}
pub(crate) fn parse_ruleset_required(json: &str) -> anyhow::Result<Vec<String>> {
let mut names: Vec<String> = Vec::new();
let mut pages = 0usize;
for page in serde_json::Deserializer::from_str(json).into_iter::<Vec<Rule>>() {
let page = page.map_err(|e| anyhow::anyhow!("cannot parse the rules payload: {e}"))?;
pages += 1;
for rule in page.iter().filter(|r| r.kind == "required_status_checks") {
for check in rule
.parameters
.iter()
.flat_map(|p| &p.required_status_checks)
{
push_unique(&mut names, &check.context);
}
}
}
anyhow::ensure!(pages > 0, "the rules payload is empty");
Ok(names)
}
pub(crate) fn read_required<R: GhRunner>(
gh: &R,
slug: &str,
base: &str,
) -> anyhow::Result<Option<Vec<String>>> {
let a = argv(&["api", &format!("repos/{slug}/branches/{base}")]);
let branch = parse_required(&gh.run(&a)?.stdout_ok(&a)?)?;
let r = argv(&[
"api",
"--paginate",
&format!("repos/{slug}/rules/branches/{base}"),
]);
let ruleset = parse_ruleset_required(&gh.run(&r)?.stdout_ok(&r)?)?;
Ok(branch.map(|mut names| {
for name in &ruleset {
push_unique(&mut names, name);
}
names
}))
}
#[derive(Debug, Default)]
pub(crate) struct CheckGate<'a> {
pub(crate) auto: bool,
pub(crate) allow_failing: &'a [String],
pub(crate) allow_no_checks: bool,
pub(crate) required: Option<&'a [String]>,
}
impl CheckGate<'_> {
fn is_required(&self, e: &RollupEntry) -> bool {
match (e.label(), self.required) {
(Some(name), Some(req)) => req.iter().any(|r| r == name),
_ => false,
}
}
fn waives(&self, e: &RollupEntry) -> bool {
let named = e
.label()
.is_some_and(|n| self.allow_failing.iter().any(|a| a.trim() == n));
named && self.required.is_some() && !self.is_required(e)
}
}
pub(crate) fn needs_required(
rollup: &[RollupEntry],
allow_failing: &[String],
allow_no_checks: bool,
) -> bool {
let checks = deciding_per_check(rollup);
(checks.is_empty() && !allow_no_checks)
|| checks.iter().any(|e| e.is_unfinished())
|| (!allow_failing.is_empty() && checks.iter().any(|e| e.failed()))
}
pub(crate) fn refusal(rollup: &[RollupEntry], gate: &CheckGate<'_>, pr: u64) -> Option<String> {
let checks = deciding_per_check(rollup);
let failing: Vec<String> = checks
.iter()
.filter(|e| e.failed() && !gate.waives(e))
.map(|e| e.display_label())
.collect();
if !failing.is_empty() {
return Some(format!(
"{} check(s) failing: {} — a failing check blocks the merge whatever branch \
protection requires (#8614); fix the branch, or pass `--allow-failing <check>` \
for a failure the PM has waived (never a required check)",
failing.len(),
failing.join(", ")
));
}
let rerun = format!("tm pr merge {pr}{}", if gate.auto { " --auto" } else { "" });
if checks.is_empty() {
let none_required = gate.required.is_none_or(<[String]>::is_empty);
return (!gate.allow_no_checks && (!gate.auto || none_required)).then(|| {
format!(
"no checks have registered yet — a merge now would land unchecked, and only \
`--auto` can wait for a required check (#8614); re-run `{rerun}` once CI \
registers, or pass `--allow-no-checks` for a repo with no CI"
)
});
}
let running: Vec<String> = checks
.iter()
.filter(|e| e.is_unfinished() && !(gate.auto && gate.is_required(e)) && !gate.waives(e))
.map(|e| e.display_label())
.collect();
(!running.is_empty()).then(|| {
format!(
"{} check(s) still running that the merge would not wait for: {} — a later \
failure would still merge (#8614); re-run `{rerun}` once they settle{}",
running.len(),
running.join(", "),
if gate.auto {
""
} else {
", or pass `--auto` to wait on required checks"
}
)
})
}
pub(crate) fn waived(rollup: &[RollupEntry], gate: &CheckGate<'_>) -> Vec<String> {
deciding_per_check(rollup)
.into_iter()
.filter(|e| (e.failed() || e.is_unfinished()) && gate.waives(e))
.map(|e| {
let state = if e.failed() {
"failed"
} else {
"still running"
};
format!("{} ({state})", e.display_label())
})
.collect()
}
pub(crate) fn commit_body(body: &str, waived: &[String]) -> String {
if waived.is_empty() {
return body.to_string();
}
format!(
"Merged over waived check(s) via `tm pr merge --allow-failing` (#8614): {}\n\n{body}",
waived.join(", ")
)
}
#[cfg(test)]
#[path = "check_gate_tests.rs"]
mod tests;