hive-forge: surface issues an issue/pr blocks in list/issue/pr
This commit is contained in:
parent
74dfd366b4
commit
a3f14f5126
4 changed files with 88 additions and 18 deletions
|
|
@ -5,7 +5,9 @@ use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
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)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -34,6 +36,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
.filter_map(|l| l.name.as_deref())
|
.filter_map(|l| l.name.as_deref())
|
||||||
.collect();
|
.collect();
|
||||||
let dependencies = dependency_summaries(client, owner, name, args.number)?;
|
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 reactions = issue_reactions(client, owner, name, args.number)?;
|
||||||
let trimmed = json!({
|
let trimmed = json!({
|
||||||
"number": issue.number,
|
"number": issue.number,
|
||||||
|
|
@ -43,6 +46,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
"assignees": assignees,
|
"assignees": assignees,
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
"dependencies": dependencies,
|
"dependencies": dependencies,
|
||||||
|
"blocking": blocking,
|
||||||
"reactions": reactions,
|
"reactions": reactions,
|
||||||
"body": issue.body,
|
"body": issue.body,
|
||||||
"attachments": attachment_json(issue.assets.as_deref()),
|
"attachments": attachment_json(issue.assets.as_deref()),
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ use forgejo_api::structs::{
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::client::Client;
|
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
|
/// What kind of items to return. Maps onto Forgejo's `type` query
|
||||||
/// parameter: `issues` / `pulls`, or no filter at all for `both`
|
/// 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);
|
return print_json(&items);
|
||||||
}
|
}
|
||||||
for item in items.as_array().into_iter().flatten() {
|
for item in items.as_array().into_iter().flatten() {
|
||||||
let progress = item
|
let number = item.get("number").and_then(Value::as_u64);
|
||||||
.get("number")
|
let progress = number
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|n| dependency_summaries(client, owner, name, n))
|
.map(|n| dependency_summaries(client, owner, name, n))
|
||||||
.transpose()?;
|
.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(
|
if let Some(msg) = trailer(
|
||||||
headers.x_total_count.and_then(|t| u64::try_from(t).ok()),
|
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
|
/// Render one issue/PR as a single `#NNN [author] title` line, plus a
|
||||||
/// `(N/M deps done)` suffix when `progress` is `Some`.
|
/// `(N/M deps done)` suffix when `progress` is `Some` and a `(blocks N)`
|
||||||
/// Defensive: missing fields drop to placeholders so a partial
|
/// suffix when `blocking_open` is nonzero — the actionability signal the
|
||||||
/// response from a future API change still produces readable output
|
/// operator asked for: an issue blocking open work is worth picking over
|
||||||
/// instead of panicking on `unwrap`.
|
/// one with no unresolved followers, at a glance in `list`'s own output
|
||||||
fn print_row(item: &Value, progress: Option<(usize, usize)>) {
|
/// 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 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 title = item.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
let author = item
|
let author = item
|
||||||
|
|
@ -261,12 +270,23 @@ fn print_row(item: &Value, progress: Option<(usize, usize)>) {
|
||||||
// (which would render every issue as a PR).
|
// (which would render every issue as a PR).
|
||||||
let is_pr = item.get("pull_request").is_some_and(|v| !v.is_null());
|
let is_pr = item.get("pull_request").is_some_and(|v| !v.is_null());
|
||||||
let kind = if is_pr { "PR" } else { " " };
|
let kind = if is_pr { "PR" } else { " " };
|
||||||
match progress {
|
let deps_suffix = progress.map(|(done, total)| format!(" ({done}/{total} deps done)"));
|
||||||
Some((done, total)) => {
|
let blocks_suffix = (blocking_open > 0).then(|| format!(" (blocks {blocking_open})"));
|
||||||
println!("#{number:>4} {kind} [{author}] {title} ({done}/{total} deps done)");
|
println!(
|
||||||
}
|
"#{number:>4} {kind} [{author}] {title}{}{}",
|
||||||
None => 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 —
|
/// Dependency completion progress from a `dependency_summaries` list —
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,50 @@ pub(crate) fn dependency_summaries(
|
||||||
.collect())
|
.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`.
|
/// Deserialize a possibly-null JSON array as an empty `Vec`.
|
||||||
///
|
///
|
||||||
/// Several Forgejo list endpoints return an explicit `null` body instead of
|
/// Several Forgejo list endpoints return an explicit `null` body instead of
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use clap::Args as ClapArgs;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::client::{Client, index};
|
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)]
|
#[derive(ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|
@ -20,6 +20,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
.repo_get_pull_request(owner, name, index(args.number)?)
|
.repo_get_pull_request(owner, name, index(args.number)?)
|
||||||
.send()?;
|
.send()?;
|
||||||
let dependencies = dependency_summaries(client, owner, name, args.number)?;
|
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 reactions = issue_reactions(client, owner, name, args.number)?;
|
||||||
let trimmed = json!({
|
let trimmed = json!({
|
||||||
"number": pull.number,
|
"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()),
|
"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()),
|
"base_branch": pull.base.as_ref().and_then(|b| b.label.as_deref()),
|
||||||
"dependencies": dependencies,
|
"dependencies": dependencies,
|
||||||
|
"blocking": blocking,
|
||||||
"reactions": reactions,
|
"reactions": reactions,
|
||||||
});
|
});
|
||||||
print_json(&trimmed)
|
print_json(&trimmed)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue