feat(#1409): hive-forge pr-status verb (mergeable, CI, reviews, last comment)

This commit is contained in:
damocles 2026-06-05 19:31:13 +02:00 committed by mara
commit 7031f57c14
5 changed files with 394 additions and 1 deletions

View file

@ -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.

View file

@ -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 <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 <verb> --help` prints the full signature for any verb.
### `pr-status`
One-stop PR health view (`--pr <n>`): 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 <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

View file

@ -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 <n>`). `--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),

View file

@ -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;

View file

@ -0,0 +1,356 @@
//! `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 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<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, repo, &sha),
(None, None) => bail!("pr-status: pass one of --pr <n> or --sha <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::<Vec<_>>()
})
.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::<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.
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<Value>)> {
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<Vec<(String, String)>> {
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<Option<(String, String)>> {
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<bool>,
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<String> = 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());
}
}