hive-forge: surface issues an issue/pr blocks in list/issue/pr

This commit is contained in:
damocles 2026-08-19 15:03:15 +02:00
commit a3f14f5126
4 changed files with 88 additions and 18 deletions

View file

@ -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()),

View file

@ -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<u64>, count: u64, page: u64, limit: u64) -> Option<Stri
}
/// Render one issue/PR as a single `#NNN [author] title` line, plus a
/// `(N/M deps done)` suffix when `progress` is `Some`.
/// 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)>) {
/// `(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 —

View file

@ -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<Vec<Value>> {
let blocking: NullableVec<BlockingIssue> = 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

View file

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