Skip to content

Commit e26fac9

Browse files
committed
feat: live gate fetch via gh + FlagNonFunctionalGate move
Closes the "no live plumbing" gap the README named as v0.1's next step, and models a sixth move class mined from a real incident hit while stewarding the estate's stuck-PR backlog today. - squabble-cli fetch <owner>/<repo> <pr>: shells to `gh` to pull the branch ruleset's required_status_checks and the PR's statusCheckRollup, and emits the resulting Gate as JSON -- pipeable straight into `squabble diagnose`. Gate-building is a pure, fixture-tested function; only the two `gh` calls touch the network. - Move::FlagNonFunctionalGate: the required check's own script fails unconditionally regardless of any exemption file present, so no PR-side change can win it. Mined from hyperpolymath/hypatia#566 (2026-07-01): standards's validate-hypatia-baseline job never read .hypatia-baseline.json's content -- confirmed by main itself failing the same check for 5+ days independent of any PR. Not auto-appliable; the point is recognition + escalation instead of re-proposing GroundTruthCheckNames against a deadlock no ground-truthing can fix. - diagnose_with_hints: a strict refinement of diagnose that lets a host with richer context (like fetch's future classifier) supply a known Move per required_context instead of the name-only default. diagnose itself is untouched -- existing callers see no behaviour change. Dogfooded against a real PR (hyperpolymath/standards#448): fetch correctly pulled the live ruleset + rollup and diagnose correctly proposed ground-truthing the one required context with no matching run name, unprompted. 15/15 tests pass; clippy clean; cargo fmt clean.
1 parent 3496235 commit e26fac9

8 files changed

Lines changed: 438 additions & 36 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/squabble-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ path = "src/main.rs"
1616

1717
[dependencies]
1818
squabble-core = { workspace = true }
19+
serde = { workspace = true }
1920
serde_json = { workspace = true }

crates/squabble-cli/src/fetch.rs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//! Live plumbing: turn a real GitHub PR into a [`squabble_core::gate::Gate`].
4+
//!
5+
//! This is the "git/`gh` plumbing" the README named as v0.1's next step. It
6+
//! shells out to the `gh` CLI (already present on every estate runner and on
7+
//! the owner's machine) rather than adding an HTTP client dependency here —
8+
//! `squabble-core` stays host-agnostic; this module is the host.
9+
//!
10+
//! Two calls, both needed to build a [`Gate`]:
11+
//!
12+
//! 1. the branch ruleset's `required_status_checks` contexts (the
13+
//! *requirement* set — what must pass, independent of what ran), and
14+
//! 2. the PR's `statusCheckRollup` (the *realised* runs on the head commit).
15+
//!
16+
//! A required context with no matching rollup entry is [`CheckRun::Missing`];
17+
//! matching-but-incomplete is [`CheckRun::Pending`]; a `SUCCESS` conclusion is
18+
//! [`CheckRun::Passed`]; anything else that completed is [`CheckRun::Failed`].
19+
20+
use serde::Deserialize;
21+
use squabble_core::gate::{CheckRun, Gate, RequiredCheck};
22+
use std::process::Command;
23+
24+
#[derive(Debug, Deserialize)]
25+
struct RollupEntry {
26+
name: String,
27+
status: Option<String>,
28+
conclusion: Option<String>,
29+
}
30+
31+
#[derive(Debug, Deserialize)]
32+
struct PrView {
33+
#[serde(rename = "baseRefName")]
34+
base_ref_name: String,
35+
#[serde(rename = "statusCheckRollup")]
36+
status_check_rollup: Vec<RollupEntry>,
37+
}
38+
39+
#[derive(Debug, Deserialize)]
40+
struct RulesetRule {
41+
#[serde(rename = "type")]
42+
rule_type: String,
43+
parameters: Option<RulesetParameters>,
44+
}
45+
46+
#[derive(Debug, Deserialize)]
47+
struct RulesetParameters {
48+
#[serde(default)]
49+
required_status_checks: Vec<RulesetContext>,
50+
}
51+
52+
#[derive(Debug, Deserialize)]
53+
struct RulesetContext {
54+
context: String,
55+
}
56+
57+
/// Parse a `gh pr view --json baseRefName,statusCheckRollup` payload into the
58+
/// realised-run half of a [`Gate`]. Pure — no IO, fully testable on fixtures.
59+
fn parse_rollup(entry: &RollupEntry) -> CheckRun {
60+
match entry.conclusion.as_deref() {
61+
Some("SUCCESS") => CheckRun::Passed,
62+
Some("FAILURE")
63+
| Some("ERROR")
64+
| Some("TIMED_OUT")
65+
| Some("CANCELLED")
66+
| Some("STARTUP_FAILURE") => CheckRun::Failed,
67+
_ => match entry.status.as_deref() {
68+
Some("COMPLETED") => CheckRun::Failed, // completed with no recognised conclusion
69+
_ => CheckRun::Pending,
70+
},
71+
}
72+
}
73+
74+
/// Build a [`Gate`] from the required-context set and the realised rollup.
75+
/// Pure and the unit of test coverage for this module — the two `gh` calls
76+
/// in [`run`] exist only to produce these two slices from a live PR.
77+
fn build_gate(required_contexts: &[String], rollup: &[RollupEntry]) -> Gate {
78+
let checks = required_contexts
79+
.iter()
80+
.map(|required| {
81+
let run = rollup
82+
.iter()
83+
.find(|r| &r.name == required)
84+
.map(parse_rollup)
85+
.unwrap_or(CheckRun::Missing);
86+
RequiredCheck::new(required.clone(), run)
87+
})
88+
.collect();
89+
Gate::new(checks)
90+
}
91+
92+
fn run_gh(args: &[&str]) -> Result<String, String> {
93+
let out = Command::new("gh")
94+
.args(args)
95+
.output()
96+
.map_err(|e| format!("failed to run `gh {}`: {e}", args.join(" ")))?;
97+
if !out.status.success() {
98+
return Err(format!(
99+
"`gh {}` exited {}: {}",
100+
args.join(" "),
101+
out.status,
102+
String::from_utf8_lossy(&out.stderr).trim()
103+
));
104+
}
105+
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
106+
}
107+
108+
/// Fetch a live PR's gate from GitHub via `gh` and return it as a [`Gate`].
109+
///
110+
/// `slug` is `owner/repo`. Requires `gh` to be authenticated for that repo —
111+
/// the same precondition every other `gh`-based estate tool already has.
112+
pub fn run(slug: &str, pr: &str) -> Result<Gate, String> {
113+
let (owner, repo) = slug
114+
.split_once('/')
115+
.ok_or_else(|| format!("expected `owner/repo`, got `{slug}`"))?;
116+
117+
let pr_json = run_gh(&[
118+
"pr",
119+
"view",
120+
pr,
121+
"--repo",
122+
slug,
123+
"--json",
124+
"baseRefName,statusCheckRollup",
125+
])?;
126+
let pr_view: PrView = serde_json::from_str(&pr_json)
127+
.map_err(|e| format!("could not parse `gh pr view` output: {e}"))?;
128+
129+
let rules_json = run_gh(&[
130+
"api",
131+
&format!(
132+
"repos/{owner}/{repo}/rules/branches/{}",
133+
pr_view.base_ref_name
134+
),
135+
])?;
136+
let rules: Vec<RulesetRule> = serde_json::from_str(&rules_json)
137+
.map_err(|e| format!("could not parse ruleset response: {e}"))?;
138+
139+
let required_contexts: Vec<String> = rules
140+
.into_iter()
141+
.filter(|r| r.rule_type == "required_status_checks")
142+
.filter_map(|r| r.parameters)
143+
.flat_map(|p| p.required_status_checks)
144+
.map(|c| c.context)
145+
.collect();
146+
147+
if required_contexts.is_empty() {
148+
return Err(format!(
149+
"no `required_status_checks` rule found on `{owner}/{repo}` branch `{}` — \
150+
an unprotected branch has no gate to squabble over",
151+
pr_view.base_ref_name
152+
));
153+
}
154+
155+
Ok(build_gate(&required_contexts, &pr_view.status_check_rollup))
156+
}
157+
158+
#[cfg(test)]
159+
mod tests {
160+
use super::*;
161+
162+
fn entry(name: &str, status: Option<&str>, conclusion: Option<&str>) -> RollupEntry {
163+
RollupEntry {
164+
name: name.to_string(),
165+
status: status.map(String::from),
166+
conclusion: conclusion.map(String::from),
167+
}
168+
}
169+
170+
#[test]
171+
fn passed_conclusion_maps_to_passed() {
172+
assert_eq!(
173+
parse_rollup(&entry("x", Some("COMPLETED"), Some("SUCCESS"))),
174+
CheckRun::Passed
175+
);
176+
}
177+
178+
#[test]
179+
fn failure_conclusion_maps_to_failed() {
180+
assert_eq!(
181+
parse_rollup(&entry("x", Some("COMPLETED"), Some("FAILURE"))),
182+
CheckRun::Failed
183+
);
184+
}
185+
186+
#[test]
187+
fn in_progress_maps_to_pending() {
188+
assert_eq!(
189+
parse_rollup(&entry("x", Some("IN_PROGRESS"), None)),
190+
CheckRun::Pending
191+
);
192+
}
193+
194+
#[test]
195+
fn required_context_absent_from_rollup_is_missing() {
196+
let gate = build_gate(&["required / never-ran".to_string()], &[]);
197+
assert_eq!(gate.checks[0].run, CheckRun::Missing);
198+
}
199+
200+
#[test]
201+
fn required_context_matched_to_realised_run_by_exact_name() {
202+
let rollup = vec![entry(
203+
"required / it-ran",
204+
Some("COMPLETED"),
205+
Some("SUCCESS"),
206+
)];
207+
let gate = build_gate(&["required / it-ran".to_string()], &rollup);
208+
assert_eq!(gate.checks[0].run, CheckRun::Passed);
209+
}
210+
211+
#[test]
212+
fn build_gate_evaluates_green_when_all_required_passed() {
213+
let rollup = vec![entry("a", Some("COMPLETED"), Some("SUCCESS"))];
214+
let gate = build_gate(&["a".to_string()], &rollup);
215+
assert_eq!(gate.evaluate(), squabble_core::gate::GateState::Green);
216+
}
217+
}

crates/squabble-cli/src/main.rs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
33
//! `squabble` — the CLI front-end to `squabble-core`.
44
//!
5-
//! v0.1 surface: `squabble diagnose <gate.json>` reads a gate description
6-
//! (the required contexts and their realised runs), runs the pure engine, and
7-
//! prints the state plus the legitimate moves it proposes. git/`gh` plumbing
8-
//! that *applies* moves is the next implementation step (see docs/CHARTER.adoc);
9-
//! this binary fails loudly rather than pretending to land anything.
5+
//! v0.1 surface: `squabble fetch <owner/repo> <pr>` turns a live PR into a
6+
//! gate (via `gh`); `squabble diagnose <gate.json>` reads a gate description
7+
//! (from `fetch`, or hand-written) and prints the state plus the legitimate
8+
//! moves the pure engine proposes. Applying a move is still the next
9+
//! implementation step (see docs/CHARTER.adoc) — this binary fails loudly
10+
//! rather than pretending to land anything.
11+
12+
mod fetch;
1013

1114
use squabble_core::{diagnose, gate::Gate};
1215
use std::process::ExitCode;
@@ -21,21 +24,47 @@ fn main() -> ExitCode {
2124
ExitCode::from(2)
2225
}
2326
},
27+
Some("fetch") => match (args.get(2), args.get(3)) {
28+
(Some(slug), Some(pr)) => run_fetch(slug, pr),
29+
_ => {
30+
eprintln!("squabble fetch: usage: squabble fetch <owner>/<repo> <pr-number>");
31+
ExitCode::from(2)
32+
}
33+
},
2434
Some("--version") | Some("-V") => {
2535
println!("squabble {}", env!("CARGO_PKG_VERSION"));
2636
ExitCode::SUCCESS
2737
}
2838
_ => {
2939
eprintln!(
3040
"squabble {} — CI/CD fighter (squabble ≠ bypass)\n\n\
31-
USAGE:\n squabble diagnose <gate.json>\n squabble --version\n",
41+
USAGE:\n squabble fetch <owner>/<repo> <pr-number>\n squabble diagnose <gate.json>\n squabble --version\n",
3242
env!("CARGO_PKG_VERSION")
3343
);
3444
ExitCode::from(2)
3545
}
3646
}
3747
}
3848

49+
fn run_fetch(slug: &str, pr: &str) -> ExitCode {
50+
match fetch::run(slug, pr) {
51+
Ok(gate) => match serde_json::to_string_pretty(&gate) {
52+
Ok(json) => {
53+
println!("{json}");
54+
ExitCode::SUCCESS
55+
}
56+
Err(e) => {
57+
eprintln!("squabble: could not serialise fetched gate: {e}");
58+
ExitCode::from(2)
59+
}
60+
},
61+
Err(e) => {
62+
eprintln!("squabble fetch: {e}");
63+
ExitCode::from(2)
64+
}
65+
}
66+
}
67+
3968
fn run_diagnose(path: &str) -> ExitCode {
4069
let text = match std::fs::read_to_string(path) {
4170
Ok(t) => t,

crates/squabble-core/src/gate.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ pub struct RequiredCheck {
4040

4141
impl RequiredCheck {
4242
pub fn new(required_context: impl Into<String>, run: CheckRun) -> Self {
43-
Self { required_context: required_context.into(), run }
43+
Self {
44+
required_context: required_context.into(),
45+
run,
46+
}
4447
}
4548

4649
/// A requirement is satisfied only by a bound run that passed. This is the
@@ -93,7 +96,11 @@ impl Gate {
9396
}
9497
return GateState::Green;
9598
}
96-
if self.checks.iter().any(|c| matches!(c.run, CheckRun::Failed)) {
99+
if self
100+
.checks
101+
.iter()
102+
.any(|c| matches!(c.run, CheckRun::Failed))
103+
{
97104
return GateState::Red;
98105
}
99106
GateState::Blocked

0 commit comments

Comments
 (0)