403 lines
13 KiB
Rust
403 lines
13 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::{CommitStatusState, IssueGetCommentsQuery};
|
|
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>"),
|
|
}
|
|
}
|
|
|
|
/// The wire string for a combined/per-context CI status state, matching
|
|
/// what the raw API emitted (`""` when absent). Shared with `pr-merge`
|
|
/// for its "CI is not green" message.
|
|
pub(crate) fn status_state_str(state: Option<CommitStatusState>) -> &'static str {
|
|
match state {
|
|
Some(CommitStatusState::Pending) => "pending",
|
|
Some(CommitStatusState::Success) => "success",
|
|
Some(CommitStatusState::Error) => "error",
|
|
Some(CommitStatusState::Failure) => "failure",
|
|
Some(CommitStatusState::Warning) => "warning",
|
|
Some(CommitStatusState::Skipped) => "skipped",
|
|
None => "",
|
|
}
|
|
}
|
|
|
|
/// 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: `(overall_state, statuses[])`.
|
|
/// Statuses ride as their serialized (API-shape) JSON so the render
|
|
/// helpers stay pure `Value` walkers.
|
|
fn fetch_combined(client: &Client, sha: &str) -> Result<(String, Vec<Value>)> {
|
|
let (owner, name) = client.owner_repo()?;
|
|
let (_, combined) = client
|
|
.api()
|
|
.repo_get_combined_status_by_ref(owner, name, sha)
|
|
.send()?;
|
|
let state = status_state_str(combined.state).to_owned();
|
|
let statuses = combined
|
|
.statuses
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(serde_json::to_value)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
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, 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 status_state_str_matches_wire_names() {
|
|
assert_eq!(
|
|
status_state_str(Some(CommitStatusState::Success)),
|
|
"success"
|
|
);
|
|
assert_eq!(
|
|
status_state_str(Some(CommitStatusState::Failure)),
|
|
"failure"
|
|
);
|
|
assert_eq!(status_state_str(None), "");
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|