refactor(hive-forge): port CLI verbs to forgejo-api

This commit is contained in:
müde 2026-07-07 09:24:53 +02:00
commit 4636987469
36 changed files with 1463 additions and 1153 deletions

View file

@ -11,10 +11,11 @@
use anyhow::{Context, Result, bail};
use clap::Args as ClapArgs;
use forgejo_api::structs::{CommitStatusState, IssueGetCommentsQuery};
use serde_json::Value;
use crate::client::Client;
use crate::verbs::print_json;
use crate::client::{Client, index};
use crate::verbs::{print_json, rfc3339};
#[derive(ClapArgs)]
pub struct Args {
@ -38,14 +39,29 @@ 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, 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, repo: &str, sha: &str) -> Result<()> {
let (state, statuses) = fetch_combined(client, repo, sha)?;
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 {
@ -60,33 +76,36 @@ fn sha_status(client: &Client, repo: &str, sha: &str) -> Result<()> {
/// 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);
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.get("mergeable").and_then(Value::as_bool);
let mergeable = pull.mergeable;
let sha = pull
.get("head")
.and_then(|h| h.get("sha"))
.and_then(Value::as_str)
.map(str::to_owned)
.head
.as_ref()
.and_then(|h| h.sha.clone())
.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 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, repo, &sha)?;
let (ci_state, ci_statuses) = fetch_combined(client, &sha)?;
let reviews = super::latest_reviews(client, repo, pr)?;
let last = last_comment(client, repo, pr)?;
let last = last_comment(client, pr)?;
if client.json_mode() {
print_json(&serde_json::json!({
@ -136,37 +155,56 @@ fn pr_status(client: &Client, repo: &str, pr: u64) -> Result<()> {
}
/// 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();
/// 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
.get("statuses")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
.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, 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));
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)
}
@ -323,6 +361,19 @@ mod tests {
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", &[]);