hive-forge list: show dep-progress count for items with open dependencies

This commit is contained in:
damocles 2026-08-18 10:10:24 +02:00 committed by mara
commit a796c24037
2 changed files with 77 additions and 10 deletions

View file

@ -15,6 +15,14 @@
//! matches it against title **and body** on this same endpoint, and a
//! text match is only useful *composed* with the other filters — a
//! separate verb would have to grow every one of them back.
//!
//! Each row in the pretty (non-`--json`) output also carries a
//! `(N/M deps done)` suffix when the item has dependencies and at least
//! one is still open — omitted entirely once every dependency is closed
//! or there are none, so a ready item's row looks exactly like it did
//! before this existed. Forgejo has no server-side dependency filter on
//! this endpoint, so this costs one extra request per row shown (not
//! per match — only the current page).
use anyhow::Result;
use clap::{Args as ClapArgs, ValueEnum};
@ -24,7 +32,7 @@ use forgejo_api::structs::{
use serde_json::Value;
use crate::client::Client;
use crate::verbs::{labels, milestone, print_json};
use crate::verbs::{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`
@ -170,7 +178,12 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
return print_json(&items);
}
for item in items.as_array().into_iter().flatten() {
print_row(item);
let progress = item
.get("number")
.and_then(Value::as_u64)
.map(|n| dependency_summaries(client, owner, name, n))
.transpose()?;
print_row(item, progress.as_deref().and_then(dep_progress));
}
if let Some(msg) = trailer(
headers.x_total_count.and_then(|t| u64::try_from(t).ok()),
@ -229,11 +242,12 @@ fn trailer(total: Option<u64>, count: u64, page: u64, limit: u64) -> Option<Stri
}
}
/// Render one issue/PR as a single `#NNN [author] title` line.
/// 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) {
fn print_row(item: &Value, progress: Option<(usize, 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
@ -247,13 +261,63 @@ fn print_row(item: &Value) {
// (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 { " " };
println!("#{number:>4} {kind} [{author}] {title}");
match progress {
Some((done, total)) => {
println!("#{number:>4} {kind} [{author}] {title} ({done}/{total} deps done)");
}
None => println!("#{number:>4} {kind} [{author}] {title}"),
}
}
/// Dependency completion progress from a `dependency_summaries` list —
/// `Some((done, total))` only when there's at least one dependency AND
/// at least one of them is still open. `None` for no dependencies, or
/// all of them already closed: the operator asked for the counter to
/// appear only "when there are deps that are not done", so a ready
/// item's row stays exactly as clean as before this existed.
fn dep_progress(deps: &[Value]) -> Option<(usize, usize)> {
let total = deps.len();
if total == 0 {
return None;
}
let done = deps
.iter()
.filter(|d| d.get("state").and_then(Value::as_str) == Some("closed"))
.count();
(done < total).then_some((done, total))
}
#[cfg(test)]
mod tests {
use super::*;
fn dep(state: &str) -> Value {
serde_json::json!({ "number": 1, "title": "x", "state": state })
}
#[test]
fn dep_progress_none_when_no_dependencies() {
assert_eq!(dep_progress(&[]), None);
}
#[test]
fn dep_progress_none_when_all_dependencies_closed() {
let deps = [dep("closed"), dep("closed")];
assert_eq!(dep_progress(&deps), None);
}
#[test]
fn dep_progress_some_when_at_least_one_dependency_still_open() {
let deps = [dep("closed"), dep("open"), dep("closed")];
assert_eq!(dep_progress(&deps), Some((2, 3)));
}
#[test]
fn dep_progress_counts_all_open_as_zero_done() {
let deps = [dep("open"), dep("open")];
assert_eq!(dep_progress(&deps), Some((0, 2)));
}
#[test]
fn kind_query_types_match_forgejo_enum() {
// The forge accepts only `issues` / `pulls` for the `type`

View file

@ -351,12 +351,15 @@ pub(crate) fn latest_reviews(client: &Client, repo: &str, pr: u64) -> Result<Vec
}
/// The current dependency list for an issue or PR — each entry names
/// another issue/PR this one is blocked on. Forgejo's dependency endpoint
/// works on the shared issue/PR index (PRs are issues internally under
/// the hood), so `issue show` and `pr show` both call this instead of
/// duplicating the fetch-and-shape step. A reviewer asked whether
/// another issue/PR this one is blocked on, with its `number`/`title`/
/// `state`. Forgejo's dependency endpoint works on the shared issue/PR
/// index (PRs are issues internally under the hood), so `issue show`,
/// `pr show`, and `list`'s dep-progress annotation all call this instead
/// of duplicating the fetch-and-shape step. A reviewer asked whether
/// `show`/`view` surface dependencies — they didn't (only `timeline`
/// rendered them, as history); this is the current-state complement.
/// `state` was added alongside `list`'s annotation so a caller can tell
/// open deps from closed ones without a second fetch.
///
/// # Errors
///
@ -373,7 +376,7 @@ pub(crate) fn dependency_summaries(
.send()?;
Ok(deps
.into_iter()
.map(|d| json!({ "number": d.number, "title": d.title }))
.map(|d| json!({ "number": d.number, "title": d.title, "state": d.state }))
.collect())
}