//! `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 serde_json::Value; use crate::client::Client; use crate::verbs::print_json; #[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, repo, &sha), (None, None) => bail!("pr-status: pass one of --pr or --sha "), } } /// CI-only path for an explicit commit. Exit code mirrors the CI verdict. fn sha_status(client: &Client, repo: &str, sha: &str) -> Result<()> { let (state, statuses) = fetch_combined(client, repo, 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 pull = client.get_json(&format!("/repos/{repo}/pulls/{pr}"))?; let title = pull.get("title").and_then(Value::as_str).unwrap_or(""); let state = pull.get("state").and_then(Value::as_str).unwrap_or("?"); let merged = pull.get("merged").and_then(Value::as_bool).unwrap_or(false); // `mergeable` is `null` while the forge is still computing it. let mergeable = pull.get("mergeable").and_then(Value::as_bool); let sha = pull .get("head") .and_then(|h| h.get("sha")) .and_then(Value::as_str) .map(str::to_owned) .with_context(|| format!("pr-status: PR #{pr} has no head.sha"))?; let requested = pull .get("requested_reviewers") .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(|u| u.get("login").and_then(Value::as_str)) .map(str::to_owned) .collect::>() }) .unwrap_or_default(); let (ci_state, ci_statuses) = fetch_combined(client, repo, &sha)?; let reviews = super::latest_reviews(client, repo, pr)?; let last = last_comment(client, repo, 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(|(l, s)| serde_json::json!({"user": l, "state": s})) .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. let changes_requested = reviews .iter() .any(|(_, verdict)| verdict == "REQUEST_CHANGES"); let ready = ci_state == "success" && mergeable == Some(true) && !changes_requested; if ready { Ok(()) } else { std::process::exit(1); } } /// Fetch the combined commit status: `(overall_state, statuses[])`. fn fetch_combined(client: &Client, repo: &str, sha: &str) -> Result<(String, Vec)> { let combined = client.get_json(&format!("/repos/{repo}/commits/{sha}/status"))?; let state = combined .get("state") .and_then(Value::as_str) .unwrap_or("") .to_owned(); let statuses = combined .get("statuses") .and_then(Value::as_array) .cloned() .unwrap_or_default(); Ok((state, 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, repo: &str, pr: u64) -> Result> { let comments = client.get_json_all(&format!("/repos/{repo}/issues/{pr}/comments"), 20)?; let last = comments .iter() .filter_map(|c| { let login = c .get("user") .and_then(|u| u.get("login")) .and_then(Value::as_str)?; let created = c.get("created_at").and_then(Value::as_str)?; Some((login.to_owned(), created.to_owned())) }) .max_by(|a, b| a.1.cmp(&b.1)); 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: &[(String, String)], 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(|(login, verdict)| { let mark = match verdict.as_str() { "APPROVED" => "✅", "REQUEST_CHANGES" => "❌", _ => "•", }; format!("{mark} {login}: {verdict}") }) .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_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()); } }