//! `pr-status --pr ` — one-stop PR health view: mergeable state, CI //! checks, requested reviewers + review verdicts, and the last-comment //! timestamp. `--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, /// Explicit commit sha (or ref) — CI-only fast path. Mutually /// exclusive with `--pr`. #[arg(long)] sha: Option, } /// # 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 or --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, } /// Deserialize a possibly-null JSON array as an empty `Vec`. fn null_as_empty<'de, D>(de: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { Ok(Option::>::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 = 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::>(), "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 { 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)> { 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> { 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, 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 = 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()); } }