Skip to content
51 changes: 33 additions & 18 deletions crates/squabble-cli/src/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,7 @@ struct RulesetContext {
/// Parse a `gh pr view --json baseRefName,statusCheckRollup` payload into the
/// realised-run half of a [`Gate`]. Pure — no IO, fully testable on fixtures.
fn parse_rollup(entry: &RollupEntry) -> CheckRun {
match entry.conclusion.as_deref() {
Some("SUCCESS") => CheckRun::Passed,
Some("SKIPPED") | Some("NEUTRAL") => CheckRun::Skipped,
Some("FAILURE")
| Some("ERROR")
| Some("TIMED_OUT")
| Some("CANCELLED")
| Some("STARTUP_FAILURE") => CheckRun::Failed,
_ => match entry.status.as_deref() {
Some("COMPLETED") => CheckRun::Failed, // completed with no recognised conclusion
_ => CheckRun::Pending,
},
}
CheckRun::from_github(entry.status.as_deref(), entry.conclusion.as_deref())
}

/// Build a [`Gate`] from the required-context set and the realised rollup.
Expand All @@ -90,11 +78,12 @@ fn build_gate(required_contexts: &[String], rollup: &[RollupEntry]) -> Gate {
let checks = required_contexts
.iter()
.map(|required| {
let run = rollup
.iter()
.find(|r| &r.name == required)
.map(parse_rollup)
.unwrap_or(CheckRun::Missing);
let run = CheckRun::for_context(
rollup
.iter()
.filter(|r| &r.name == required)
.map(parse_rollup),
);
RequiredCheck::new(required.clone(), run)
})
.collect();
Expand Down Expand Up @@ -648,6 +637,32 @@ pub fn run_bundle(slug: &str, pr: &str) -> Result<FetchBundle, FetchError> {
})
}

/// Every rule type the base branch's rulesets carry, deduplicated, in order.
fn rule_types_from_json(rules_json: &str) -> Result<Vec<String>, String> {
let rules: Vec<RulesetRule> = serde_json::from_str(rules_json)
.map_err(|e| format!("could not parse ruleset response: {e}"))?;
let mut out: Vec<String> = Vec::new();
for t in rules.into_iter().map(|r| r.rule_type) {
if !out.contains(&t) {
out.push(t);
}
}
Ok(out)
}

/// The base branch's gate as `verify-satisfied` needs it: the required-context
/// union (rulesets ∪ classic protection) and every ruleset rule type.
///
/// Unlike [`run_bundle`] an empty context set is *not* `NoGate` here — "done"
/// is still a meaningful question on an ungated branch. A 403 on classic
/// protection is still a hard error, for the same vacuous-green reason.
pub fn base_gate(slug: &str, branch: &str) -> Result<(Vec<String>, Vec<String>), FetchError> {
let rules_json = run_gh(&["api", &format!("repos/{slug}/rules/branches/{branch}")])?;
let protection = probe_classic_protection(slug, branch)?;
let contexts = required_contexts_from_apis(&rules_json, &protection)?;
Ok((contexts, rule_types_from_json(&rules_json)?))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
10 changes: 8 additions & 2 deletions crates/squabble-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
//! - `2` — a genuine failure: bad usage, `gh` failed, unparseable input.
//! - `4` — **blocking chain finding** (`squabble chains`): a dependency cycle
//! or a dead upstream. Like `3`, a reportable finding, not a tool failure.
//! - `5` — **not done** (`squabble verify-satisfied`): agent items remain on
//! the PR (unresolved threads, a failing required check, automerge unarmed…).
//! - `3` — **no gate**: the PR's base branch carries no `required_status_checks`
//! ruleset rule, so there is nothing to triage.
//!
Expand All @@ -34,6 +36,7 @@ mod boj;
mod chains;
mod fetch;
mod fight;
mod verify;

use squabble_core::{diagnose, gate::Gate};
use std::process::ExitCode;
Expand All @@ -57,6 +60,7 @@ fn main() -> ExitCode {
},
Some("fight") => fight::run(&args[2..]),
Some("chains") => chains::run(&args[2..]),
Some("verify-satisfied") => verify::run(&args[2..]),
Some("--version") | Some("-V") => {
println!("squabble {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
Expand All @@ -71,12 +75,14 @@ fn main() -> ExitCode {
squabble diagnose <gate.json>\n \
squabble {}\n \
squabble {}\n \
squabble {}\n \
squabble --version\n\n\
EXIT CODES:\n \
0 ok · 2 failure · 3 no `required_status_checks` rule on the base branch · 4 chains: blocking finding\n",
0 ok · 2 failure · 3 no `required_status_checks` rule on the base branch · 4 chains: blocking finding · 5 verify-satisfied: agent items remain\n",
env!("CARGO_PKG_VERSION"),
fight::USAGE.trim_start_matches("usage: squabble "),
chains::USAGE.trim_start_matches("usage: squabble ")
chains::USAGE.trim_start_matches("usage: squabble "),
verify::USAGE.trim_start_matches("usage: squabble ")
);
ExitCode::from(2)
}
Expand Down
157 changes: 157 additions & 0 deletions crates/squabble-cli/src/verify.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//! `squabble verify-satisfied <owner/repo> <pr>` — is this PR actually done?
//!
//! Reads the PR (GraphQL, `squabble-forge::pr_done`) and its base branch's
//! gate (REST, `fetch::base_gate`), then asks the pure evaluator in
//! `squabble-core::done`. The verdict goes to stdout as JSON; a human-readable
//! list goes to stderr. Exit `5` means agent work remains — the one answer a
//! hook needs, so hook and human read the same implementation.

use crate::fetch;
use serde::Serialize;
use squabble_core::done::{evaluate, PrState, Verdict, DEFAULT_REVIEW_APPS};
use squabble_forge::pr_done::fetch_pr_done;
use squabble_forge::GhTransport;
use std::process::ExitCode;

pub const USAGE: &str = "usage: squabble verify-satisfied <owner>/<repo> <pr-number>";

/// Exit code for "the PR is not done: agent items remain".
pub const NOT_DONE_EXIT: u8 = 5;

#[derive(Serialize)]
struct Report<'a> {
repo: &'a str,
pr: u64,
head_oid: String,
base_ref: String,
done: bool,
#[serde(flatten)]
verdict: Verdict,
}

pub fn run(args: &[String]) -> ExitCode {
let (slug, pr) = match args {
[slug, pr] => match (slug.split_once('/'), pr.parse::<u64>()) {
(Some(_), Ok(n)) => (slug.as_str(), n),
_ => {
eprintln!("squabble verify-satisfied: {USAGE}");
return ExitCode::from(2);
}
},
_ => {
eprintln!("squabble verify-satisfied: {USAGE}");
return ExitCode::from(2);
}
};
let (owner, name) = slug.split_once('/').expect("checked above");

let read = match fetch_pr_done(&GhTransport, owner, name, pr) {
Ok(r) => r,
Err(e) => {
eprintln!("squabble verify-satisfied: {e}");
return ExitCode::from(2);
}
};
let mut facts = read.facts;
// The base gate only bears on a PR that can still merge: for a merged or
// closed PR it feeds nothing but the evidence-free listing, never an agent
// item (pinned by `the_base_gate_never_decides_a_closed_or_merged_verdict`).
// Skipping the REST call there keeps the commonest done-claim ("landed X")
// off the rate limit this PAT shares with every other session.
if facts.state == PrState::Open {
match fetch::base_gate(slug, &read.base_ref) {
Ok((contexts, rule_types)) => {
facts.required_contexts = contexts;
facts.rule_types = rule_types;
}
Err(e) => {
eprintln!("squabble verify-satisfied: {e}");
return ExitCode::from(fetch::FetchError::FAILED_EXIT);
}
}
}

let verdict = evaluate(&facts, DEFAULT_REVIEW_APPS);
let done = verdict.is_done();
eprint!("{}", render(slug, pr, &verdict));
let report = Report {
repo: slug,
pr,
head_oid: read.head_oid,
base_ref: read.base_ref,
done,
verdict,
};
match serde_json::to_string_pretty(&report) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("squabble verify-satisfied: could not serialise verdict: {e}");
return ExitCode::from(2);
}
}
if done {
ExitCode::SUCCESS
} else {
ExitCode::from(NOT_DONE_EXIT)
}
}

fn render(slug: &str, pr: u64, v: &Verdict) -> String {
let mut s = format!(
"{slug}#{pr}: {}\n",
if v.is_done() {
"DONE (nothing left for the agent)"
} else {
"NOT DONE"
}
);
let mut section = |title: &str, lines: Vec<String>| {
if !lines.is_empty() {
s.push_str(&format!(" {title}:\n"));
for l in lines {
s.push_str(&format!(" - {l}\n"));
}
}
};
section(
"agent must act",
v.agent_items.iter().map(|i| i.describe()).collect(),
);
section(
"held by a human",
v.held_by_human.iter().map(|i| i.describe()).collect(),
);
section(
"satisfied without evidence (skipped/neutral)",
v.evidence_free.clone(),
);
section("notes", v.notes.clone());
s
}

#[cfg(test)]
mod tests {
use super::*;

/// A hook keys on this number; it must not collide with any other code the
/// binary already uses (2 failure, 3 no gate, 4 chains blocking).
#[test]
fn not_done_has_its_own_exit_code() {
for other in [0u8, 2, 3, 4] {
assert_ne!(NOT_DONE_EXIT, other);
}
}

#[test]
fn malformed_arguments_are_a_usage_failure_not_a_verdict() {
for bad in [
vec![],
vec!["norepo".to_string(), "1".to_string()],
vec!["o/r".to_string(), "x".to_string()],
] {
assert_eq!(run(&bad), ExitCode::from(2));
}
}
}
Loading
Loading