hyperhive/hive-forge/src/verbs/pr_status.rs
atlas 80650041d9 fix(#2752): pr-status must not abort on a repo with no CI
On a repo with no CI configured forgejo returns the combined-status
`statuses` field as an explicit `null` rather than `[]`.
`#[serde(default)]` only covers a *missing* key — a present null still
fails to deserialize, so `pr-status` died with
`invalid type: null, expected a sequence` instead of reporting the PR.

Deserialize the field through an `Option<Vec<_>>` so both null and
absent map to an empty vec.

Closes #2752.
2026-07-27 10:11:01 +02:00

448 lines
15 KiB
Rust

//! `pr-status --pr <n>` — one-stop PR health view: mergeable state, CI
//! checks, requested reviewers + review verdicts, and the last-comment
//! timestamp. `--sha <sha>` is a CI-only fast path for a raw commit.
//! Removes the need for raw `curl` to the statuses / reviews endpoints,
//! keeping forge access on the single `hive-forge` tool.
//!
//! Exit code is a merge-readiness verdict for `--pr`: 0 only when CI is
//! green AND the PR is mergeable AND no review requests changes — so it
//! composes (`hive-forge pr-status --pr 42 && …`). `--sha` mirrors the
//! CI verdict alone (0 = success).
use anyhow::{Context, Result, bail};
use clap::Args as ClapArgs;
use forgejo_api::structs::IssueGetCommentsQuery;
use serde::Deserialize;
use serde_json::Value;
use crate::client::{Client, index};
use crate::verbs::{print_json, rfc3339};
#[derive(ClapArgs)]
pub struct Args {
/// PR number — full health view (mergeable, CI, reviews, last
/// comment). Mutually exclusive with `--sha`.
#[arg(long, conflicts_with = "sha")]
pr: Option<u64>,
/// Explicit commit sha (or ref) — CI-only fast path. Mutually
/// exclusive with `--pr`.
#[arg(long)]
sha: Option<String>,
}
/// # Errors
///
/// Returns an error if neither `--pr` nor `--sha` is given, or any forge
/// GET fails (PR lookup, combined status, reviews, comments). A
/// not-ready verdict is NOT an error — it's reported and reflected in
/// the process exit code instead.
pub fn run(client: &Client, args: Args) -> Result<()> {
let repo = client.repo();
match (args.pr, args.sha) {
(Some(pr), _) => pr_status(client, repo, pr),
(None, Some(sha)) => sha_status(client, &sha),
(None, None) => bail!("pr-status: pass one of --pr <n> or --sha <sha>"),
}
}
/// Lenient mirror of the combined-status response.
///
/// The typed client models `state` as an enum, but a commit with no CI
/// contexts at all comes back as `"state": ""` — not a member of that
/// enum — so the typed call fails to deserialize and the whole verb
/// dies on exactly the PRs where "no CI ran" is the useful answer.
/// Keeping `state` a plain `String` (and the per-context statuses as
/// opaque `Value`s, which the render helpers already walk) means an
/// unknown or empty state is reported rather than fatal.
///
/// `statuses` needs the same leniency for a different reason: on a repo
/// with no CI at all the field comes back as an explicit `null` rather
/// than `[]`, and `#[serde(default)]` only covers a *missing* key — a
/// present null still fails to deserialize. So a doc-only repo makes the
/// whole verb error out on exactly the PRs where "no CI here" is the
/// answer worth printing.
#[derive(serde::Deserialize, Default)]
pub(crate) struct CombinedStatus {
#[serde(default)]
pub state: String,
#[serde(default, deserialize_with = "null_as_empty")]
pub statuses: Vec<Value>,
}
/// Deserialize a possibly-null JSON array as an empty `Vec`.
fn null_as_empty<'de, D>(de: D) -> Result<Vec<Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<Vec<Value>>::deserialize(de)?.unwrap_or_default())
}
/// CI-only path for an explicit commit. Exit code mirrors the CI verdict.
fn sha_status(client: &Client, sha: &str) -> Result<()> {
let (state, statuses) = fetch_combined(client, sha)?;
if client.json_mode() {
print_json(&combined_json(sha, &state, &statuses))?;
} else {
print_ci(sha, &state, &statuses);
}
if state == "success" {
Ok(())
} else {
std::process::exit(1);
}
}
/// Full PR health view. Exit code is a merge-readiness verdict.
fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> {
let (owner, name) = client.owner_repo()?;
let pull = client
.api()
.repo_get_pull_request(owner, name, index(pr)?)
.send()?;
let title = pull.title.as_deref().unwrap_or("");
let state = pull.state.map_or("?", |s| match s {
forgejo_api::structs::StateType::Open => "open",
forgejo_api::structs::StateType::Closed => "closed",
});
let merged = pull.merged.unwrap_or(false);
// `mergeable` is `null` while the forge is still computing it.
let mergeable = pull.mergeable;
let sha = pull
.head
.as_ref()
.and_then(|h| h.sha.clone())
.with_context(|| format!("pr-status: PR #{pr} has no head.sha"))?;
let requested: Vec<String> = pull
.requested_reviewers
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|u| u.login.clone())
.collect();
let (ci_state, ci_statuses) = fetch_combined(client, &sha)?;
let reviews = super::latest_reviews(client, repo, pr)?;
let last = last_comment(client, pr)?;
if client.json_mode() {
print_json(&serde_json::json!({
"number": pr,
"title": title,
"state": state,
"merged": merged,
"mergeable": mergeable,
"head_sha": sha,
"ci_state": ci_state,
"ci_statuses": ci_statuses,
"requested_reviewers": requested,
"reviews": reviews
.iter()
.map(|r| serde_json::json!({
"user": r.login, "state": r.state,
"stale": r.stale, "dismissed": r.dismissed,
}))
.collect::<Vec<_>>(),
"last_comment": last
.as_ref()
.map(|(l, t)| serde_json::json!({"user": l, "created_at": t})),
}))?;
} else {
print_pr(
pr,
title,
state,
merged,
mergeable,
&sha,
&ci_state,
&ci_statuses,
&requested,
&reviews,
last.as_ref(),
);
}
// Merge-readiness: CI green, mergeable, and nobody requesting changes.
// A *superseded* REQUEST_CHANGES — stale (branch moved since) or
// dismissed — no longer applies to the current head, so it doesn't hold
// up the verdict.
let changes_requested = reviews
.iter()
.any(|r| r.state == "REQUEST_CHANGES" && !r.superseded());
let ready = ci_state == "success" && mergeable == Some(true) && !changes_requested;
if ready {
Ok(())
} else {
std::process::exit(1);
}
}
/// Fetch the combined commit status for `sha` in the client's repo.
/// Goes through the raw-JSON escape hatch rather than the typed client
/// so an empty (no-CI) state stays reportable — see [`CombinedStatus`].
///
/// # Errors
///
/// Returns an error if `repo` isn't a well-formed `owner/name`, or if
/// the status GET fails (transport, non-2xx, or a body that isn't even
/// loosely the expected shape). An empty state is NOT an error — that's
/// the case this function exists to report.
pub(crate) fn fetch_combined_in(client: &Client, repo: &str, sha: &str) -> Result<CombinedStatus> {
let (owner, name) = crate::client::split_repo(repo)?;
client.get_api_json(&format!("/repos/{owner}/{name}/commits/{sha}/status"), &[])
}
/// Fetch the combined commit status: `(overall_state, statuses[])`.
/// Statuses ride as their API-shape JSON so the render helpers stay
/// pure `Value` walkers.
fn fetch_combined(client: &Client, sha: &str) -> Result<(String, Vec<Value>)> {
let combined = fetch_combined_in(client, client.repo(), sha)?;
Ok((combined.state, combined.statuses))
}
/// The most recent issue comment on the PR, as `(login, created_at)`.
/// Comments page oldest-first; we drain (bounded) and take the max
/// timestamp so a long thread still reports the genuinely-latest one.
fn last_comment(client: &Client, pr: u64) -> Result<Option<(String, String)>> {
const PAGE_SIZE: u32 = 50;
const MAX_PAGES: u32 = 20;
let (owner, name) = client.owner_repo()?;
let idx = index(pr)?;
let mut last: Option<(String, String)> = None;
for page in 1..=MAX_PAGES {
let (_, comments) = client
.api()
.issue_get_comments(owner, name, idx, IssueGetCommentsQuery::default())
.page(page)
.page_size(PAGE_SIZE)
.send()?;
let short = comments.len() < PAGE_SIZE as usize;
for c in &comments {
let Some(login) = c.user.as_ref().and_then(|u| u.login.clone()) else {
continue;
};
let Some(created) = rfc3339(c.created_at) else {
continue;
};
if last.as_ref().is_none_or(|(_, t)| created > *t) {
last = Some((login, created));
}
}
if short {
break;
}
}
Ok(last)
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Emoji marker for a per-context CI status string.
fn status_mark(status: &str) -> &'static str {
match status {
"success" => "",
"pending" => "",
"failure" => "",
"error" => "⚠️ ",
"warning" => "🟡",
_ => "",
}
}
/// Render the CI section (overall verdict + per-context rows).
fn print_ci(sha: &str, state: &str, statuses: &[Value]) {
let short = &sha[..sha.len().min(12)];
if statuses.is_empty() {
println!("{short}: no CI statuses reported (CI may not have started)");
return;
}
let verdict = match state {
"success" => "✅ success",
"pending" => "⏳ pending",
"failure" => "❌ failure",
"error" => "⚠️ error",
"" => "❔ unknown",
other => other,
};
println!("{short}: {verdict} ({} context(s))", statuses.len());
for s in statuses {
let ctx = s.get("context").and_then(Value::as_str).unwrap_or("?");
let st = s
.get("status")
.or_else(|| s.get("state"))
.and_then(Value::as_str)
.unwrap_or("?");
let desc = s.get("description").and_then(Value::as_str).unwrap_or("");
let mark = status_mark(st);
if desc.is_empty() {
println!(" {mark} {ctx}: {st}");
} else {
println!(" {mark} {ctx}: {st}{desc}");
}
if matches!(st, "failure" | "error")
&& let Some(url) = s.get("target_url").and_then(Value::as_str)
&& !url.is_empty()
{
println!("{url}");
}
}
}
/// Render the full PR health block.
#[allow(
clippy::too_many_arguments,
reason = "pure display helper — the args are the already-fetched PR fields \
it prints; a struct would just mirror the API response for a \
single call site"
)]
fn print_pr(
pr: u64,
title: &str,
state: &str,
merged: bool,
mergeable: Option<bool>,
sha: &str,
ci_state: &str,
ci_statuses: &[Value],
requested: &[String],
reviews: &[super::ReviewInfo],
last_comment: Option<&(String, String)>,
) {
println!("PR #{pr}: {title}");
let state_line = if merged {
"merged".to_owned()
} else {
let m = match mergeable {
Some(true) => "mergeable: yes",
Some(false) => "mergeable: NO (conflicts)",
None => "mergeable: computing…",
};
format!("{state} ({m})")
};
println!(" state: {state_line}");
print!(" CI: ");
print_ci(sha, ci_state, ci_statuses);
if requested.is_empty() {
println!(" reviewers: (none requested)");
} else {
println!(" reviewers: {} requested", requested.join(", "));
}
if reviews.is_empty() {
println!(" reviews: (none)");
} else {
let rendered: Vec<String> = reviews
.iter()
.map(|r| {
let mark = match r.state.as_str() {
// A superseded approval (stale/dismissed) no longer
// satisfies branch protection; flag it so the CLI doesn't
// read as still-good.
"APPROVED" if r.superseded() => "⚠️",
"APPROVED" => "",
"REQUEST_CHANGES" => "",
_ => "",
};
let login = &r.login;
let state = &r.state;
let suffix = if r.stale {
" (stale — needs re-review on current head)"
} else if r.dismissed {
" (dismissed)"
} else {
""
};
format!("{mark} {login}: {state}{suffix}")
})
.collect();
println!(" reviews: {}", rendered.join(", "));
}
if let Some((login, when)) = last_comment {
println!(" last comment: {when} by {login}");
} else {
println!(" last comment: (none)");
}
}
/// Reconstruct the combined-status JSON shape for `--json --sha`.
fn combined_json(sha: &str, state: &str, statuses: &[Value]) -> Value {
serde_json::json!({
"sha": sha,
"state": state,
"statuses": statuses,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_sha_truncates() {
let sha = "0123456789abcdef0123456789abcdef";
assert_eq!(&sha[..sha.len().min(12)], "0123456789ab");
}
#[test]
fn short_sha_handles_already_short() {
assert_eq!(&"abc123"[.."abc123".len().min(12)], "abc123");
}
#[test]
fn status_mark_maps_states() {
assert_eq!(status_mark("success"), "");
assert_eq!(status_mark("failure"), "");
assert_eq!(status_mark("pending"), "");
assert_eq!(status_mark("weird"), "");
}
#[test]
fn combined_status_accepts_empty_state() {
// A commit with no CI contexts: Forgejo emits `"state": ""`,
// which the typed client's enum rejects outright.
let c: CombinedStatus =
serde_json::from_str(r#"{"state":"","statuses":[],"sha":"abc"}"#).unwrap();
assert_eq!(c.state, "");
assert!(c.statuses.is_empty());
}
#[test]
fn combined_status_keeps_known_state_and_contexts() {
let c: CombinedStatus = serde_json::from_str(
r#"{"state":"success","statuses":[{"status":"success","context":"ci"}]}"#,
)
.unwrap();
assert_eq!(c.state, "success");
assert_eq!(c.statuses.len(), 1);
}
#[test]
fn combined_status_tolerates_missing_fields() {
let c: CombinedStatus = serde_json::from_str("{}").unwrap();
assert_eq!(c.state, "");
assert!(c.statuses.is_empty());
}
#[test]
fn combined_status_tolerates_null_statuses() {
// A repo with no CI configured: the field is present and null,
// which `#[serde(default)]` alone does not cover.
let c: CombinedStatus =
serde_json::from_str(r#"{"state":"","statuses":null,"sha":"abc"}"#).unwrap();
assert!(c.statuses.is_empty());
}
#[test]
fn combined_json_shape() {
let v = combined_json("abc", "success", &[]);
assert_eq!(v["sha"], "abc");
assert_eq!(v["state"], "success");
assert!(v["statuses"].as_array().unwrap().is_empty());
}
}