use serde::{Deserialize, Serialize};
use super::rollup::{RollupEntry, deciding_runs};
use super::{EXIT_BLOCKED, EXIT_OK, GhRunner, argv, repo_slug};
use crate::cli::PrQueueCheckArgs;
const HOLD_LABELS: [&str; 2] = ["hold", "do-not-merge"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Verdict {
pub(crate) number: u64,
pub(crate) mergeable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) reason: Option<String>,
}
impl Verdict {
pub(crate) fn line(&self) -> String {
match &self.reason {
None => format!("#{} MERGEABLE", self.number),
Some(r) => format!("#{} BLOCKED: {r}", self.number),
}
}
}
#[derive(Debug, Deserialize)]
struct PrListRow {
number: u64,
}
#[derive(Debug, Deserialize)]
struct Label {
name: String,
}
#[derive(Debug, Deserialize)]
struct Comment {
#[serde(default)]
body: String,
}
#[derive(Debug, Deserialize)]
struct PrView {
#[serde(default)]
#[serde(rename = "isDraft")]
is_draft: bool,
#[serde(default)]
labels: Vec<Label>,
#[serde(default)]
#[serde(rename = "reviewDecision")]
review_decision: Option<String>,
#[serde(default)]
mergeable: Option<String>,
#[serde(default)]
#[serde(rename = "mergeStateStatus")]
merge_state_status: Option<String>,
#[serde(default)]
#[serde(rename = "statusCheckRollup")]
rollup: Vec<RollupEntry>,
#[serde(default)]
comments: Vec<Comment>,
}
fn stop_reason(view: &PrView, required: &[String]) -> Option<String> {
if view.is_draft {
return Some("draft".to_string());
}
if let Some(l) = view.labels.iter().find(|l| is_hold_label(&l.name)) {
return Some(format!("hold label `{}`", l.name));
}
if view
.review_decision
.as_deref()
.is_some_and(|d| d.eq_ignore_ascii_case("CHANGES_REQUESTED"))
{
return Some("review decision CHANGES_REQUESTED".to_string());
}
if latest_critic_verdict(&view.comments) == Some(CriticVerdict::Block) {
return Some("unresolved code-critic BLOCK in the PR comments".to_string());
}
if let Some(reason) = mergeability_reason(view) {
return Some(reason);
}
for context in required {
let runs = deciding_runs(&view.rollup, context);
if runs.is_empty() {
return Some(format!(
"required context `{context}` is missing on the head SHA"
));
}
if runs.iter().any(|e| e.settled() && !e.is_success()) {
return Some(format!("required context `{context}` is not SUCCESS"));
}
if let Some(e) = runs.iter().find(|e| e.is_unfinished()) {
return Some(format!(
"required context `{context}` is pending: a run has no result yet {}",
e.run_summary()
));
}
}
None
}
fn is_hold_label(name: &str) -> bool {
let lower = name.trim().to_ascii_lowercase();
HOLD_LABELS.iter().any(|h| {
lower == *h || lower.starts_with(&format!("{h}/")) || lower.starts_with(&format!("{h}:"))
})
}
fn mergeability_reason(view: &PrView) -> Option<String> {
let mergeable = view.mergeable.as_deref();
let merge_state = view.merge_state_status.as_deref();
if mergeable.is_some_and(|m| m.eq_ignore_ascii_case("CONFLICTING")) {
return Some("mergeable CONFLICTING — resolve with `gh pr update-branch`".to_string());
}
if merge_state.is_some_and(|s| s.eq_ignore_ascii_case("DIRTY")) {
return Some("mergeStateStatus DIRTY — resolve with `gh pr update-branch`".to_string());
}
match mergeable {
None => {
return Some("mergeable field is missing on the head SHA".to_string());
}
Some(m) if m.eq_ignore_ascii_case("UNKNOWN") => {
return Some("mergeable is UNKNOWN — GitHub is still computing it; retry".to_string());
}
_ => {}
}
match merge_state {
None => {
return Some("mergeStateStatus field is missing on the head SHA".to_string());
}
Some(s) if s.eq_ignore_ascii_case("UNKNOWN") => {
return Some(
"mergeStateStatus is UNKNOWN — GitHub is still computing it; retry".to_string(),
);
}
_ => {}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CriticVerdict {
Block,
Cleared,
}
fn latest_critic_verdict(comments: &[Comment]) -> Option<CriticVerdict> {
let mut latest = None;
for c in comments {
if !c.body.to_ascii_lowercase().contains("code-critic") {
continue;
}
for token in c.body.split(|ch: char| !ch.is_ascii_alphabetic()) {
match token {
"BLOCK" => latest = Some(CriticVerdict::Block),
"APPROVE" | "WARN" => latest = Some(CriticVerdict::Cleared),
_ => {}
}
}
}
latest
}
pub(crate) fn required_contexts<R: GhRunner>(
gh: &R,
slug: &str,
base: &str,
) -> anyhow::Result<Vec<String>> {
let path = format!("repos/{slug}/branches/{base}/protection");
let a = argv(&["api", &path, "--jq", ".required_status_checks.contexts[]"]);
let stdout = gh.run(&a)?.stdout_ok(&a)?;
let contexts: Vec<String> = stdout
.lines()
.map(|l| l.trim().trim_matches('"').to_string())
.filter(|l| !l.is_empty())
.collect();
anyhow::ensure!(
!contexts.is_empty(),
"branch protection for `{base}` lists no required status checks; \
refusing to report every PR mergeable off an empty gate list"
);
Ok(contexts)
}
pub(crate) fn run<R: GhRunner>(gh: &R, args: &PrQueueCheckArgs) -> anyhow::Result<i32> {
let slug = repo_slug(gh, args.repo.as_deref())?;
let required = required_contexts(gh, &slug, &args.base)?;
let numbers = match args.pr {
Some(n) => vec![n],
None => list_open_prs(gh, &slug, &args.base)?,
};
let verdicts = verdicts(gh, &slug, &required, &numbers)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&verdicts)?);
} else {
for v in &verdicts {
println!("{}", v.line());
}
}
Ok(if verdicts.iter().all(|v| v.mergeable) {
EXIT_OK
} else {
EXIT_BLOCKED
})
}
pub(crate) fn verdicts<R: GhRunner>(
gh: &R,
slug: &str,
required: &[String],
numbers: &[u64],
) -> anyhow::Result<Vec<Verdict>> {
let mut out = Vec::with_capacity(numbers.len());
for n in numbers {
let view = pr_view(gh, slug, *n)?;
let reason = stop_reason(&view, required);
out.push(Verdict {
number: *n,
mergeable: reason.is_none(),
reason,
});
}
Ok(out)
}
fn list_open_prs<R: GhRunner>(gh: &R, slug: &str, base: &str) -> anyhow::Result<Vec<u64>> {
let a = argv(&[
"pr",
"list",
"--repo",
slug,
"--base",
base,
"--state",
"open",
"--limit",
"100",
"--json",
"number,author,assignees,isDraft,labels,headRefName",
]);
let stdout = gh.run(&a)?.stdout_ok(&a)?;
let rows: Vec<PrListRow> = serde_json::from_str(&stdout)
.map_err(|e| anyhow::anyhow!("cannot parse `gh pr list` JSON: {e}"))?;
let mut numbers: Vec<u64> = rows.into_iter().map(|r| r.number).collect();
numbers.sort_unstable();
Ok(numbers)
}
fn pr_view<R: GhRunner>(gh: &R, slug: &str, pr: u64) -> anyhow::Result<PrView> {
let n = pr.to_string();
let a = argv(&[
"pr",
"view",
&n,
"--repo",
slug,
"--json",
"isDraft,labels,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,comments",
]);
let stdout = gh.run(&a)?.stdout_ok(&a)?;
serde_json::from_str(&stdout)
.map_err(|e| anyhow::anyhow!("cannot parse `gh pr view {pr}` JSON: {e}"))
}