use super::{gh, gh_json};
use anyhow::{bail, Context, Result};
use serde_json::Value;
pub fn checks(n: u64) -> Result<(usize, usize, usize)> {
let r = rollups(&[n])?.remove(&n).context("GitHub said nothing of its checks")?;
Ok(gate(&r.checks))
}
#[derive(Clone, Debug, Default)]
pub struct Rollup {
pub head: String,
pub checks: Vec<Value>,
pub unknown: bool,
}
pub fn rollups(ns: &[u64]) -> Result<std::collections::HashMap<u64, Rollup>> {
let chunks: Vec<&[u64]> = ns.chunks(40).collect();
let mut out = std::collections::HashMap::new();
for group in chunks.chunks(4) {
let got: Vec<Result<Vec<(u64, Rollup)>>> = std::thread::scope(|s| {
let handles: Vec<_> = group.iter().map(|c| s.spawn(move || rollup_chunk(c))).collect();
handles.into_iter().map(|h| h.join().unwrap_or_else(|p| Err(anyhow::anyhow!(crate::batch::panicked(&p))))).collect()
});
for r in got {
match r {
Ok(r) => out.extend(r),
Err(e) if chunks.len() == 1 => return Err(e),
Err(_) => {}
}
}
}
anyhow::ensure!(!out.is_empty() || ns.is_empty() || chunks.len() == 1, "GitHub answered none of its batches of checks");
Ok(out)
}
fn refused_prs(v: &serde_json::Value) -> Result<(Vec<u64>, Vec<u64>)> {
let (mut bad, mut unread) = (vec![], vec![]);
for e in v["errors"].as_array().into_iter().flatten() {
let path = e["path"].as_array().cloned().unwrap_or_default();
let pr = path.iter().filter_map(|k| k.as_str()?.strip_prefix('p')?.parse::<u64>().ok()).next();
match pr {
Some(n) if path.iter().any(|k| k == "baseRef") => unread.push(n),
Some(n) => bad.push(n),
None => anyhow::bail!("GitHub: {}", e["message"].as_str().unwrap_or("refused")),
}
}
Ok((bad, unread))
}
fn protected(base: &str) -> Option<Vec<String>> {
type Cache = std::collections::HashMap<String, (std::time::Instant, Option<Vec<String>>)>;
static CACHE: std::sync::LazyLock<std::sync::Mutex<Cache>> = std::sync::LazyLock::new(Default::default);
let fresh = |c: &&(std::time::Instant, Option<Vec<String>>)| c.0.elapsed() < std::time::Duration::from_secs(if c.1.is_some() { 600 } else { 30 });
if let Some(v) = CACHE.lock().ok()?.get(base).filter(fresh) {
return v.1.clone();
}
let path: String = base.bytes().map(|b| match b.is_ascii_alphanumeric() || b"/._-".contains(&b) {
true => (b as char).to_string(),
false => format!("%{b:02X}"),
}).collect();
let got: Option<Vec<String>> = (!base.is_empty())
.then(|| gh_json(&["api", &format!("repos/{{owner}}/{{repo}}/branches/{path}")]).ok())
.flatten()
.map(|b| b["protection"]["required_status_checks"]["contexts"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_owned)).collect());
if let Ok(mut c) = CACHE.lock() {
c.insert(base.to_owned(), (std::time::Instant::now(), got.clone()));
}
got
}
fn rollup_chunk(ns: &[u64]) -> Result<Vec<(u64, Rollup)>> {
let prs: String = ns
.iter()
.map(|n| {
format!(
"p{n}: pullRequest(number: {n}) {{ state baseRefName \
baseRef {{ rules(first: 50) {{ nodes {{ type parameters {{ ... on RequiredStatusChecksParameters {{ requiredStatusChecks {{ context }} }} }} }} }} }} \
commits(last: 1) {{ nodes {{ commit {{ oid statusCheckRollup {{ contexts(first: 100) {{ pageInfo {{ hasNextPage }} nodes {{ \
... on CheckRun {{ name status conclusion detailsUrl isRequired(pullRequestNumber: {n}) }} \
... on StatusContext {{ context state targetUrl isRequired(pullRequestNumber: {n}) }} }} }} }} }} }} }} }}"
)
})
.collect::<Vec<_>>()
.join(" ");
let q = format!("query($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{ {prs} }} }}");
let v = gh_json(&["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}", "-f", &format!("query={q}")])?;
let (bad, unread) = refused_prs(&v)?;
if !ns.is_empty() && ns.iter().all(|n| bad.contains(n)) {
super::refused(&v)?;
}
let repo = &v["data"]["repository"];
anyhow::ensure!(repo.is_object(), "GitHub: no repository in its answer");
Ok(ns
.iter()
.filter(|n| !bad.contains(n))
.filter_map(|&n| {
let pr = repo.get(format!("p{n}")).filter(|p| !p.is_null())?;
let commit = &pr["commits"]["nodes"][0]["commit"];
let contexts = &commit["statusCheckRollup"]["contexts"];
let mut checks = contexts["nodes"].as_array().cloned().unwrap_or_default();
let rules = pr["baseRef"]["rules"]["nodes"].as_array().into_iter().flatten().filter(|r| r["type"] == "REQUIRED_STATUS_CHECKS");
let mut want: Vec<String> = rules.flat_map(|r| r["parameters"]["requiredStatusChecks"].as_array().cloned().unwrap_or_default()).filter_map(|c| c["context"].as_str().map(str::to_owned)).collect();
let open = pr["state"] == "OPEN";
let protection = open.then(|| protected(pr["baseRefName"].as_str().unwrap_or(""))).flatten();
let unknown = open && (unread.contains(&n) || protection.is_none());
want.extend(protection.unwrap_or_default());
want.sort();
want.dedup(); if pr["state"] == "OPEN" && contexts["pageInfo"]["hasNextPage"] != true {
let missing: Vec<String> = want.into_iter().filter(|w| !checks.iter().any(|c| check_name(c) == w.as_str())).collect();
checks.extend(missing.into_iter().map(|name| serde_json::json!({ "name": name, "status": "EXPECTED", "isRequired": true })));
}
Some((n, Rollup { head: commit["oid"].as_str().unwrap_or("").to_owned(), checks, unknown }))
})
.collect())
}
pub(crate) fn check_name(c: &Value) -> &str {
c["name"].as_str().or(c["context"].as_str()).unwrap_or("?")
}
pub(crate) fn optional(c: &Value, checks: &[Value]) -> bool {
c["isRequired"] != true && checks.iter().any(|c| c["isRequired"] == true)
}
pub(crate) fn gate(checks: &[Value]) -> (usize, usize, usize) {
let req: Vec<Value> = checks.iter().filter(|c| !optional(c, checks)).cloned().collect();
tally(&req)
}
pub fn mark_ready(n: u64) -> Result<()> {
let (_, fail, pending) = checks(n)?;
match (fail, pending) {
(0, 0) => gh(&["pr", "ready", &n.to_string()]).map(drop),
(0, k) => bail!("CI is still running ({k} check{} pending): ready once it passes", if k == 1 { "" } else { "s" }),
(k, _) => bail!("CI fails ({k} required check{} failed): fix it first (dv list: a a has Claude look)", if k == 1 { "" } else { "s" }),
}
}
pub(crate) fn tally(checks: &[Value]) -> (usize, usize, usize) {
let count = |m| checks.iter().filter(|c| verdict(c) == m).count();
(count('✓'), count('✗'), count('●'))
}
pub(crate) fn verdict(c: &Value) -> char {
match (c["status"].as_str(), c["conclusion"].as_str().or(c["state"].as_str())) {
(Some(st), _) if st != "COMPLETED" => '●',
(_, Some("SUCCESS" | "NEUTRAL" | "SKIPPED")) => '✓',
(_, Some("PENDING" | "EXPECTED")) | (_, None) => '●',
_ => '✗',
}
}
pub fn rerun_failed(head: &str) -> Result<String> {
let q = ".[] | select(.conclusion == \"failure\" or .conclusion == \"timed_out\" or .conclusion == \"cancelled\") | .databaseId";
let out = gh(&["run", "list", "--commit", head, "--limit", "100", "--json", "databaseId,conclusion", "-q", q])?;
let ids: Vec<&str> = out.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
if ids.is_empty() {
return Ok("no failed runs to rerun (they may be running already)".into());
}
let mut errors = vec![];
for batch in ids.chunks(4) {
std::thread::scope(|s| {
let runs: Vec<_> = batch.iter().map(|id| (id, s.spawn(move || gh(&["run", "rerun", id, "--failed"]).err().map(|e| format!("run {id}: {e:#}"))))).collect();
errors.extend(runs.into_iter().filter_map(|(id, r)| r.join().unwrap_or_else(|p| Some(format!("run {id}: {}", crate::batch::panicked(&p))))));
});
}
rerun_says(ids.len(), &errors)
}
pub(crate) fn rerun_says(total: usize, errors: &[String]) -> Result<String> {
let rerun = total - errors.len();
match (rerun, errors.first()) {
(0, Some(e)) => anyhow::bail!("no failed run could be rerun: {e}"),
(_, Some(e)) => Ok(format!("{rerun} of {total} failed runs rerunning ({} couldn't be: {e})", errors.len())),
_ => Ok(format!("{rerun} failed run(s) rerunning")),
}
}
pub fn awaiting_approval(sha: Option<&str>) -> Result<std::collections::HashMap<String, Vec<u64>>> {
let mut ep = "repos/{owner}/{repo}/actions/runs?status=action_required&per_page=100".to_owned();
if let Some(s) = sha {
ep += &format!("&head_sha={s}");
}
let v: Value = gh_json(&["api", &ep])?;
let mut out: std::collections::HashMap<String, Vec<u64>> = Default::default();
for r in v["workflow_runs"].as_array().into_iter().flatten() {
if let (Some(sha), Some(id)) = (r["head_sha"].as_str(), r["id"].as_u64()) {
out.entry(sha.to_owned()).or_default().push(id);
}
}
Ok(out)
}
pub fn approve_runs(sha: &str) -> Result<usize> {
let ids = awaiting_approval(Some(sha))?.remove(sha).unwrap_or_default();
for id in &ids {
gh(&["api", "-X", "POST", &format!("repos/{{owner}}/{{repo}}/actions/runs/{id}/approve")])?;
}
Ok(ids.len())
}
#[test]
fn one_refused_pr_leaves_the_others() {
let v = serde_json::json!({ "errors": [
{ "path": ["repository", "p12", "commits", "nodes", 0], "message": "no" },
{ "path": ["repository", "p13", "baseRef", "rules"], "message": "can't read rules" } ] });
assert_eq!(refused_prs(&v).unwrap(), (vec![12], vec![13]));
let whole = serde_json::json!({ "errors": [{ "message": "rate limited" }] });
assert!(refused_prs(&whole).is_err());
}