refactor(hive-forge): port CLI verbs to forgejo-api
This commit is contained in:
parent
b8a3927c43
commit
4636987469
36 changed files with 1463 additions and 1153 deletions
|
|
@ -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", &[]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue