use crate::rules::{Evidence, Finding, Rule, Stance, Trend};
use crate::shell::Parsed;
pub const RULE: Rule = Rule {
id: "forge-merge-by-hand",
default_stance: Stance::Advise,
evidence: Evidence {
per_1000: 13.8,
measured: "2026-09-10",
trend: Trend::Flat(5),
},
examine,
confirm: None,
};
fn is_pr_merge_path(text: &str) -> bool {
let mut rest = text;
while let Some(i) = rest.find("/pulls/") {
let after = &rest[i + "/pulls/".len()..];
let digits = after.len() - after.trim_start_matches(|c: char| c.is_ascii_digit()).len();
if digits > 0 {
let tail = &after[digits..];
if tail == "/merge" || tail.starts_with("/merge?") || tail.starts_with("/merge/") {
return true;
}
}
rest = after;
}
false
}
fn is_http_client(program: &str) -> bool {
matches!(
program,
"curl" | "wget" | "http" | "https" | "httpie" | "xh"
)
}
fn examine(parsed: &Parsed) -> Option<Finding> {
for cmd in parsed.judgeable() {
let Some(program) = cmd.program() else {
continue;
};
let raw_client = is_http_client(program);
let gh_api = program == "gh" && cmd.subcommand() == Some("api");
if !raw_client && !gh_api {
continue;
}
let Some(hit) = cmd.args().iter().find(|w| is_pr_merge_path(&w.text)) else {
continue;
};
return Some(Finding {
reason: format!(
"`{program}` against a pull request's merge endpoint merges immediately. \
The endpoint does not consult check runs — it answers 200 whether the \
run passed, failed, or has not started — so nothing in this command \
establishes that CI was green."
),
remedy: "Use the merge-when-green skill: poll the checks to completion, read \
the conclusion as its own step, then merge. Never chain the poll and \
the merge into one command — the merge runs regardless of what the \
poll printed."
.to_string(),
span: hit.at..cmd.end,
});
}
None
}
#[cfg(test)]
mod tests {
use super::is_pr_merge_path;
#[test]
fn matches_a_numbered_merge_child() {
assert!(is_pr_merge_path(
"https://git.daddyshome.fr/api/v1/repos/fredericrous/sre-agent/pulls/10/merge"
));
assert!(is_pr_merge_path(
"https://api.github.com/repos/o/r/pulls/1234/merge?foo=1"
));
}
#[test]
fn ignores_reads_and_unnumbered_paths() {
assert!(!is_pr_merge_path(
"https://git.daddyshome.fr/api/v1/repos/o/r/pulls/10"
));
assert!(!is_pr_merge_path(
"https://git.daddyshome.fr/api/v1/repos/o/r/pulls"
));
assert!(!is_pr_merge_path(
"https://git.daddyshome.fr/api/v1/repos/o/r/pulls/merge"
));
assert!(!is_pr_merge_path("https://example.com/merge"));
}
}