From a3f14f512618741967baee7aec92240665875cfa Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 19 Aug 2026 15:03:15 +0200 Subject: [PATCH] hive-forge: surface issues an issue/pr blocks in list/issue/pr --- hive-forge/src/verbs/issue.rs | 6 +++- hive-forge/src/verbs/list.rs | 52 ++++++++++++++++++++++++----------- hive-forge/src/verbs/mod.rs | 44 +++++++++++++++++++++++++++++ hive-forge/src/verbs/pr.rs | 4 ++- 4 files changed, 88 insertions(+), 18 deletions(-) diff --git a/hive-forge/src/verbs/issue.rs b/hive-forge/src/verbs/issue.rs index 50c8a692..038a84dc 100644 --- a/hive-forge/src/verbs/issue.rs +++ b/hive-forge/src/verbs/issue.rs @@ -5,7 +5,9 @@ use clap::Args as ClapArgs; use serde_json::json; use crate::client::{Client, index}; -use crate::verbs::{attachment_json, dependency_summaries, issue_reactions, print_json}; +use crate::verbs::{ + attachment_json, blocking_summaries, dependency_summaries, issue_reactions, print_json, +}; #[derive(ClapArgs)] pub struct Args { @@ -34,6 +36,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { .filter_map(|l| l.name.as_deref()) .collect(); let dependencies = dependency_summaries(client, owner, name, args.number)?; + let blocking = blocking_summaries(client, owner, name, args.number)?; let reactions = issue_reactions(client, owner, name, args.number)?; let trimmed = json!({ "number": issue.number, @@ -43,6 +46,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { "assignees": assignees, "labels": labels, "dependencies": dependencies, + "blocking": blocking, "reactions": reactions, "body": issue.body, "attachments": attachment_json(issue.assets.as_deref()), diff --git a/hive-forge/src/verbs/list.rs b/hive-forge/src/verbs/list.rs index fb2f35cb..0d308101 100644 --- a/hive-forge/src/verbs/list.rs +++ b/hive-forge/src/verbs/list.rs @@ -32,7 +32,7 @@ use forgejo_api::structs::{ use serde_json::Value; use crate::client::Client; -use crate::verbs::{dependency_summaries, labels, milestone, print_json}; +use crate::verbs::{blocking_summaries, dependency_summaries, labels, milestone, print_json}; /// What kind of items to return. Maps onto Forgejo's `type` query /// parameter: `issues` / `pulls`, or no filter at all for `both` @@ -178,12 +178,18 @@ pub fn run(client: &Client, args: Args) -> Result<()> { return print_json(&items); } for item in items.as_array().into_iter().flatten() { - let progress = item - .get("number") - .and_then(Value::as_u64) + let number = item.get("number").and_then(Value::as_u64); + let progress = number .map(|n| dependency_summaries(client, owner, name, n)) .transpose()?; - print_row(item, progress.as_deref().and_then(dep_progress)); + let blocks = number + .map(|n| blocking_summaries(client, owner, name, n)) + .transpose()?; + print_row( + item, + progress.as_deref().and_then(dep_progress), + blocks.as_deref().map_or(0, blocking_open_count), + ); } if let Some(msg) = trailer( headers.x_total_count.and_then(|t| u64::try_from(t).ok()), @@ -243,11 +249,14 @@ fn trailer(total: Option, count: u64, page: u64, limit: u64) -> Option) { +/// `(N/M deps done)` suffix when `progress` is `Some` and a `(blocks N)` +/// suffix when `blocking_open` is nonzero — the actionability signal the +/// operator asked for: an issue blocking open work is worth picking over +/// one with no unresolved followers, at a glance in `list`'s own output +/// rather than a per-issue `show`. Defensive: missing fields drop to +/// placeholders so a partial response from a future API change still +/// produces readable output instead of panicking on `unwrap`. +fn print_row(item: &Value, progress: Option<(usize, usize)>, blocking_open: usize) { let number = item.get("number").and_then(Value::as_u64).unwrap_or(0); let title = item.get("title").and_then(Value::as_str).unwrap_or(""); let author = item @@ -261,12 +270,23 @@ fn print_row(item: &Value, progress: Option<(usize, usize)>) { // (which would render every issue as a PR). let is_pr = item.get("pull_request").is_some_and(|v| !v.is_null()); let kind = if is_pr { "PR" } else { " " }; - match progress { - Some((done, total)) => { - println!("#{number:>4} {kind} [{author}] {title} ({done}/{total} deps done)"); - } - None => println!("#{number:>4} {kind} [{author}] {title}"), - } + let deps_suffix = progress.map(|(done, total)| format!(" ({done}/{total} deps done)")); + let blocks_suffix = (blocking_open > 0).then(|| format!(" (blocks {blocking_open})")); + println!( + "#{number:>4} {kind} [{author}] {title}{}{}", + deps_suffix.unwrap_or_default(), + blocks_suffix.unwrap_or_default() + ); +} + +/// Count of *open* issues `blocking` this row's issue blocks — closed +/// followers don't count toward "actionable" (see [`print_row`]'s doc +/// comment); they're already done regardless of this one's own state. +fn blocking_open_count(blocking: &[Value]) -> usize { + blocking + .iter() + .filter(|b| b.get("state").and_then(Value::as_str) == Some("open")) + .count() } /// Dependency completion progress from a `dependency_summaries` list — diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index b48e9965..6e901211 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -382,6 +382,50 @@ pub(crate) fn dependency_summaries( .collect()) } +/// One entry in a `GET .../blocks` response — same `number`/`title`/ +/// `state` shape [`dependency_summaries`] maps down to, but only those +/// fields: this struct exists to be lenient (see +/// [`Client::get_api_json`]'s doc comment), not to model the full Issue +/// response body. +#[derive(Deserialize)] +struct BlockingIssue { + #[serde(default)] + number: u64, + #[serde(default)] + title: String, + #[serde(default)] + state: String, +} + +/// The issues *blocked by* `number` — the reverse of +/// [`dependency_summaries`]. Forgejo's dependency API is one-directional +/// in `forgejo-api`'s generated client (only the forward `GET +/// .../dependencies` is wrapped), but the reverse route is real — `GET +/// .../blocks`, confirmed against Forgejo/Gitea's actual API surface, just +/// not covered by the crate — so this goes through +/// [`Client::get_api_json`] instead, same escape hatch [`issue_reactions`] +/// already uses for an uncovered route. +/// +/// # Errors +/// +/// Propagates the forge API errors from listing blocking issues. +pub(crate) fn blocking_summaries( + client: &Client, + owner: &str, + name: &str, + number: u64, +) -> Result> { + let blocking: NullableVec = client.get_api_json( + &format!("/repos/{owner}/{name}/issues/{number}/blocks"), + &[], + )?; + Ok(blocking + .0 + .into_iter() + .map(|b| json!({ "number": b.number, "title": b.title, "state": b.state })) + .collect()) +} + /// Deserialize a possibly-null JSON array as an empty `Vec`. /// /// Several Forgejo list endpoints return an explicit `null` body instead of diff --git a/hive-forge/src/verbs/pr.rs b/hive-forge/src/verbs/pr.rs index 0302d218..3cb90d45 100644 --- a/hive-forge/src/verbs/pr.rs +++ b/hive-forge/src/verbs/pr.rs @@ -5,7 +5,7 @@ use clap::Args as ClapArgs; use serde_json::json; use crate::client::{Client, index}; -use crate::verbs::{dependency_summaries, issue_reactions, print_json}; +use crate::verbs::{blocking_summaries, dependency_summaries, issue_reactions, print_json}; #[derive(ClapArgs)] pub struct Args { @@ -20,6 +20,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { .repo_get_pull_request(owner, name, index(args.number)?) .send()?; let dependencies = dependency_summaries(client, owner, name, args.number)?; + let blocking = blocking_summaries(client, owner, name, args.number)?; let reactions = issue_reactions(client, owner, name, args.number)?; let trimmed = json!({ "number": pull.number, @@ -31,6 +32,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { "head_branch": pull.head.as_ref().and_then(|h| h.label.as_deref()), "base_branch": pull.base.as_ref().and_then(|b| b.label.as_deref()), "dependencies": dependencies, + "blocking": blocking, "reactions": reactions, }); print_json(&trimmed)