diff --git a/CLAUDE.md b/CLAUDE.md index d1d682d0..0982738d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -291,7 +291,8 @@ hive-forge/ Forgejo CLI wrapper (`hive-forge` binary) issue-edit, pr-create, pr-reviews, assign, close, labels, list, milestone, branches, tree-sha, diff, subscription, attach-issue, - attach-comment, attachment-get, lint). Replaces the + attach-comment, attachment-get, lint, + pr-status). Replaces the 600-line hive-forge-tools.nix bash script. hive-matrix-mcp/ per-agent matrix-sdk integration. diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 2a9f8570..96a3f53a 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -45,6 +45,8 @@ hive-forge lint unassigned # open issues/PRs with no assignee hive-forge lint no-reviewer --reviewer argus # PRs missing a reviewer comment from argus hive-forge lint stale-branches --days 14 # branches with no recent activity hive-forge lint assignments # per-assignee open item count +hive-forge pr-status --pr 42 # PR health: mergeable, CI, reviews, last comment (exit 0 = ready) +hive-forge pr-status --sha # CI-only fast path for an explicit commit sha hive-forge timeline 42 # audit trail: closes, label changes, assignments, commit refs hive-forge attach-issue 42 /path/to/file # upload a file attachment to an issue; prints download URL hive-forge attach-comment 18042 /path/to/file # upload a file attachment to a comment; prints download URL @@ -55,6 +57,34 @@ hive-forge subscription --unwatch # unsubscribe `hive-forge --help` prints the full signature for any verb. +### `pr-status` + +One-stop PR health view (`--pr `): mergeable state, CI checks, +requested reviewers + review verdicts, and the last-comment timestamp — +the things you need to know whether a PR is ready to merge (CI must pass +before merge). `--sha ` is a CI-only fast path for a raw commit. + +A failing/erroring CI context prints its job link. The process 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 && echo ready`. `--sha` mirrors the CI +verdict alone. + +``` +hive-forge pr-status --pr 42 +# PR #42: feat(...): ... +# state: open (mergeable: yes) +# CI: e39a87ea3949: ✅ success (1 context(s)) +# ✅ CI / nix flake check (pull_request): success — Successful in 1m50s +# reviewers: (none requested) +# reviews: ✅ argus: APPROVED +# last comment: 2026-06-05T19:13:28+02:00 by argus +``` + +Note: review verdicts come from *formal* Forgejo reviews (the +approve / request-changes API). Reviewers who post their verdict as a +plain comment show under `last comment`, not `reviews`. + ## Notes - `comment --body "..."` with backticks in the body: always use diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 9ff41bd7..219b2a51 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -73,6 +73,10 @@ enum Verb { Close(verbs::close::Args), /// List, add, or remove labels on an issue or PR. Labels(verbs::labels::Args), + /// PR health view: mergeable state, CI checks, requested reviewers + + /// review verdicts, last-comment time (`--pr `). `--sha` is a + /// CI-only fast path. Exit code is a merge-readiness verdict. + PrStatus(verbs::pr_status::Args), /// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments). Lint(verbs::lint::Args), /// List issues / PRs with filters (`--kind`, `--state`, `--assignee`, @@ -121,6 +125,7 @@ fn main() -> Result<()> { Verb::Assign(a) => verbs::assign::run(&client, a), Verb::Close(a) => verbs::close::run(&client, a), Verb::Labels(a) => verbs::labels::run(&client, a), + Verb::PrStatus(a) => verbs::pr_status::run(&client, a), Verb::Lint(a) => verbs::lint::run(&client, a), Verb::List(a) => verbs::list::run(&client, a), Verb::Milestone(a) => verbs::milestone::run(&client, a), diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index cb2c697e..29743197 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -23,6 +23,7 @@ pub mod milestone; pub mod pr; pub mod pr_create; pub mod pr_reviews; +pub mod pr_status; pub mod subscription; pub mod timeline; pub mod tree_sha; diff --git a/hive-forge/src/verbs/pr_status.rs b/hive-forge/src/verbs/pr_status.rs new file mode 100644 index 00000000..89b51d16 --- /dev/null +++ b/hive-forge/src/verbs/pr_status.rs @@ -0,0 +1,356 @@ +//! `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 = 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)) +} + +/// Latest non-comment review verdict per reviewer, as `(login, state)`. +/// Reviews come oldest-first; a later one supersedes an earlier one from +/// the same user. `COMMENT` / `PENDING` reviews carry no verdict and are +/// skipped. +fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result> { + let reviews = client.get_json_all(&format!("/repos/{repo}/pulls/{pr}/reviews"), 10)?; + let mut latest: Vec<(String, String)> = Vec::new(); + for r in &reviews { + let Some(login) = r + .get("user") + .and_then(|u| u.get("login")) + .and_then(Value::as_str) + else { + continue; + }; + let st = r.get("state").and_then(Value::as_str).unwrap_or(""); + if st == "COMMENT" || st == "PENDING" || st.is_empty() { + continue; + } + if let Some(slot) = latest.iter_mut().find(|(l, _)| l == login) { + st.clone_into(&mut slot.1); + } else { + latest.push((login.to_owned(), st.to_owned())); + } + } + Ok(latest) +} + +/// 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)] +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()); + } +}