Forgejo's issue-list endpoint answers 'was this merged?' only inside the nested pull_request object, while state says closed for a merged PR and for one closed without merging alike. So the obvious top-level query is null or ambiguous for every row, and with a // default it renders as a confident 'nothing merged' that cannot ever be right -- a wrong answer shaped exactly like a clean one. Copy merged and merged_at up to the top level of each PR row so the obvious query is the correct one. Additive: the nested object is left untouched so an existing consumer keeps working, and issue rows have no pull_request and pass through unchanged. The head branch is deliberately not hoisted: this endpoint does not carry it at all -- the row's ref is an empty string, not the branch -- so there is nothing to lift. pr show has head_branch.
512 lines
20 KiB
Rust
512 lines
20 KiB
Rust
//! `list [--kind issue|pr|both] [--state open|closed|all] [--assignee
|
|
//! <user>] [--author <user>] [--label <name>] [--milestone <name>]
|
|
//! [--search <text>] [--limit N] [--page N]` —
|
|
//! list issues / PRs with filters. Pretty `#NNN [author] title` output by
|
|
//! default; `--json` for piping. `--limit` is the page size and `--page`
|
|
//! the 1-based page number — walk pages incrementally for a large result
|
|
//! set rather than pulling the whole population into one response.
|
|
//!
|
|
//! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string
|
|
//! filters one-for-one so the mental model carries over. Closes the
|
|
//! read-side curl-fallback gap (no boundary concerns —
|
|
//! every agent + the operator queries the issue tracker constantly).
|
|
//!
|
|
//! `--search` is why there is no separate `search` verb: the forge
|
|
//! 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};
|
|
use forgejo_api::structs::{
|
|
IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
|
|
};
|
|
use serde_json::Value;
|
|
|
|
use crate::client::Client;
|
|
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`
|
|
/// (Forgejo returns issues + PRs when `type` is absent — same slice
|
|
/// the forge UI's "Issues" tab shows without a type filter).
|
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
|
#[clap(rename_all = "kebab-case")]
|
|
pub enum Kind {
|
|
/// Issues only (excludes PRs).
|
|
Issue,
|
|
/// Pull requests only.
|
|
Pr,
|
|
/// Issues + pull requests (default).
|
|
Both,
|
|
}
|
|
|
|
impl Kind {
|
|
fn query_type(self) -> Option<IssueListIssuesQueryType> {
|
|
match self {
|
|
Self::Issue => Some(IssueListIssuesQueryType::Issues),
|
|
Self::Pr => Some(IssueListIssuesQueryType::Pulls),
|
|
Self::Both => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// State filter. Matches Forgejo's `state` query parameter
|
|
/// (`open` / `closed` / `all`).
|
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
|
#[clap(rename_all = "kebab-case")]
|
|
pub enum State {
|
|
Open,
|
|
Closed,
|
|
All,
|
|
}
|
|
|
|
impl State {
|
|
fn query_state(self) -> IssueListIssuesQueryState {
|
|
match self {
|
|
Self::Open => IssueListIssuesQueryState::Open,
|
|
Self::Closed => IssueListIssuesQueryState::Closed,
|
|
Self::All => IssueListIssuesQueryState::All,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(ClapArgs)]
|
|
pub struct Args {
|
|
/// What to return: issues, PRs, or both (default: both).
|
|
#[arg(long, value_enum, default_value_t = Kind::Both)]
|
|
kind: Kind,
|
|
/// Issue/PR state (default: open).
|
|
#[arg(long, value_enum, default_value_t = State::Open)]
|
|
state: State,
|
|
/// Filter to items assigned to this user (single login).
|
|
#[arg(long)]
|
|
assignee: Option<String>,
|
|
/// Filter to items authored by this user (single login).
|
|
#[arg(long)]
|
|
author: Option<String>,
|
|
/// Filter to items mentioning this user.
|
|
#[arg(long)]
|
|
mention: Option<String>,
|
|
/// Filter to items carrying any of these label names. Repeatable.
|
|
/// Validated client-side: a name the forge can't resolve is dropped
|
|
/// from the filter rather than rejected, which returns MORE results
|
|
/// than asked for, not fewer.
|
|
#[arg(long = "label")]
|
|
labels: Vec<String>,
|
|
/// Filter to items in any of these milestones, by title or id.
|
|
/// Repeatable. Validated client-side against the repo's milestones
|
|
/// (closed ones included), since the forge would silently discard a
|
|
/// name it can't resolve and return the UNFILTERED list.
|
|
#[arg(long = "milestone")]
|
|
milestones: Vec<String>,
|
|
/// Full-text search over title AND body, server-side. Composes with
|
|
/// every filter above — this is the duplicate-hunting path that
|
|
/// grepping `list` output can't cover, since grep only ever sees
|
|
/// the titles.
|
|
#[arg(long)]
|
|
search: Option<String>,
|
|
/// Page size — items per page (default: 30). The forge clamps this
|
|
/// to its own `api.MAX_RESPONSE_ITEMS` (50 by default), so a large
|
|
/// `--limit` silently returns a smaller page; the trailer reports
|
|
/// the real total rather than trusting this number. Must be >= 1.
|
|
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))]
|
|
limit: u64,
|
|
/// Page number to fetch (1-based, default 1). Combine with `--limit`
|
|
/// to page through large result sets incrementally.
|
|
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u64).range(1..))]
|
|
page: u64,
|
|
}
|
|
|
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
|
let (owner, name) = client.owner_repo()?;
|
|
// Validate the name-based filters BEFORE querying. The forge
|
|
// *discards* a label or milestone it can't resolve instead of
|
|
// rejecting it, so a typo doesn't narrow the result set — it returns
|
|
// the UNFILTERED one. That doesn't waste a query, it inverts the
|
|
// answer: "is anything open in this milestone" comes back as every
|
|
// open issue and reads as yes, and a duplicate check gets a list that
|
|
// never narrowed and concludes there isn't one.
|
|
//
|
|
// The ids are discarded on purpose — this endpoint filters by name,
|
|
// so resolution here is a spell-check, not a lookup. `resolve_ids` is
|
|
// reused rather than reimplemented so the message stays identical to
|
|
// the one the write side has always produced.
|
|
//
|
|
// Costs one extra round-trip per filtered invocation, and only when
|
|
// the filter is actually used.
|
|
if !args.labels.is_empty() {
|
|
let all = labels::repo_labels(client)?;
|
|
labels::resolve_ids(&all, &args.labels)?;
|
|
}
|
|
if !args.milestones.is_empty() {
|
|
let all = milestone::repo_milestones(client, "all")?;
|
|
milestone::ensure_filters_resolve(&all, &args.milestones)?;
|
|
}
|
|
let query = IssueListIssuesQuery {
|
|
state: Some(args.state.query_state()),
|
|
// The forge parses `labels` as a comma-separated list of names.
|
|
labels: (!args.labels.is_empty()).then(|| args.labels.join(",")),
|
|
q: args.search.clone().filter(|s| !s.is_empty()),
|
|
r#type: args.kind.query_type(),
|
|
// Same comma-separated shape as `labels` above.
|
|
milestones: (!args.milestones.is_empty()).then(|| args.milestones.join(",")),
|
|
since: None,
|
|
before: None,
|
|
created_by: args.author.clone().filter(|s| !s.is_empty()),
|
|
assigned_by: args.assignee.clone().filter(|s| !s.is_empty()),
|
|
mentioned_by: args.mention.clone().filter(|s| !s.is_empty()),
|
|
sort: None,
|
|
};
|
|
let (headers, issues) = client
|
|
.api()
|
|
.issue_list_issues(owner, name, query)
|
|
.page(u32::try_from(args.page).unwrap_or(u32::MAX))
|
|
.page_size(u32::try_from(args.limit).unwrap_or(u32::MAX))
|
|
.send()?;
|
|
let count = issues.len() as u64;
|
|
let mut items = serde_json::to_value(issues)?;
|
|
if client.json_mode() {
|
|
hoist_merge_state(&mut items);
|
|
return print_json(&items);
|
|
}
|
|
for item in items.as_array().into_iter().flatten() {
|
|
let number = item.get("number").and_then(Value::as_u64);
|
|
let progress = number
|
|
.map(|n| dependency_summaries(client, owner, name, n))
|
|
.transpose()?;
|
|
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()),
|
|
count,
|
|
args.page,
|
|
args.limit,
|
|
) {
|
|
eprintln!("{msg}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Tell the caller, on stderr, whether this page is the whole answer.
|
|
///
|
|
/// The forge clamps `page_size` to its own `api.MAX_RESPONSE_ITEMS`, so
|
|
/// the requested `limit` is not the page size that was served: with
|
|
/// `--limit 400` a full clamped page of 50 comes back, and the old
|
|
/// `count == limit` test stayed silent — suppressing the warning in the
|
|
/// one case where the truncation is invisible. Hence the header.
|
|
///
|
|
/// `total` is `X-Total-Count`, which Forgejo sends on this endpoint. The
|
|
/// *effective* page size isn't reported, but it doesn't need to be: a
|
|
/// short page is by definition the last one, so whenever more pages can
|
|
/// exist at all, `count` **is** the page size and `page * count` is how
|
|
/// many rows have been shown up to here.
|
|
///
|
|
/// Deliberately not clamping `--limit` client-side to 50: that number is
|
|
/// the *remote's* configuration, and baking a peer's setting into the
|
|
/// client is how you get a value that's wrong on the one deployment that
|
|
/// changed it.
|
|
fn trailer(total: Option<u64>, count: u64, page: u64, limit: u64) -> Option<String> {
|
|
// An empty page is the end, whatever else is true.
|
|
if count == 0 {
|
|
return None;
|
|
}
|
|
match total {
|
|
Some(total) => {
|
|
let shown = page.saturating_mul(count);
|
|
let remaining = total.saturating_sub(shown);
|
|
(remaining > 0).then(|| {
|
|
format!(
|
|
"… {shown} of {total} shown (page {page}, {count}/page) — {remaining} more; re-run with --page {}.",
|
|
page + 1
|
|
)
|
|
})
|
|
}
|
|
// No header (older forge, or a proxy that dropped it): fall back
|
|
// to the full-page heuristic. It under-reports when the server
|
|
// clamped, which is why it is the fallback and not the rule.
|
|
None => (count == limit).then(|| {
|
|
format!(
|
|
"… {count} shown (page {page}); more results may exist — re-run with --page {}.",
|
|
page + 1
|
|
)
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Render one issue/PR as a single `#NNN [author] title` line, plus a
|
|
/// `(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`.
|
|
/// Copy a PR row's `merged` / `merged_at` from the nested `pull_request`
|
|
/// object up to the top level of the row.
|
|
///
|
|
/// Forgejo's issue-list endpoint answers "was this merged?" only inside
|
|
/// `pull_request`, while `state` says `closed` for a merged PR *and* one
|
|
/// closed without merging. So the obvious top-level query — `.merged_at`,
|
|
/// or `.state` — is null/ambiguous for **every** row, and with a `//`
|
|
/// default it renders as a confident "nothing merged" that cannot ever be
|
|
/// right. Hoisting makes the obvious query the correct one instead of
|
|
/// leaving a trap only a nested path avoids.
|
|
///
|
|
/// Additive: the original `pull_request` object is left untouched, so a
|
|
/// consumer already reading the nested path keeps working. Issue rows have
|
|
/// no `pull_request` and pass through unchanged.
|
|
///
|
|
/// ⚠️ The head branch is deliberately NOT hoisted — this endpoint does not
|
|
/// carry it at all. The row's `ref` is an **empty string**, not the branch,
|
|
/// so there is nothing to lift; `pr show <n>` has `head_branch`.
|
|
fn hoist_merge_state(items: &mut Value) {
|
|
let Some(rows) = items.as_array_mut() else {
|
|
return;
|
|
};
|
|
for row in rows {
|
|
let Some(pr) = row.get("pull_request") else {
|
|
continue;
|
|
};
|
|
let merged = pr.get("merged").cloned();
|
|
let merged_at = pr.get("merged_at").cloned();
|
|
let Some(obj) = row.as_object_mut() else {
|
|
continue;
|
|
};
|
|
if let Some(v) = merged {
|
|
obj.insert("merged".to_owned(), v);
|
|
}
|
|
if let Some(v) = merged_at {
|
|
obj.insert("merged_at".to_owned(), v);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
.get("user")
|
|
.and_then(|u| u.get("login"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("?");
|
|
// Tag PRs visually so a mixed-kind result stays scannable. The
|
|
// forge always populates `pull_request` but sets it to `null` for
|
|
// issues; we check non-null specifically rather than just-present
|
|
// (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 { " " };
|
|
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 —
|
|
/// `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 })
|
|
}
|
|
|
|
/// A merged PR, a closed-but-unmerged PR and a plain issue in one
|
|
/// array — because telling the first two apart is the entire point,
|
|
/// and `state` cannot: it reads `closed` for both.
|
|
#[test]
|
|
fn hoisting_separates_merged_from_closed_unmerged_and_leaves_issues_alone() {
|
|
let mut items = serde_json::json!([
|
|
{ "number": 1, "state": "closed",
|
|
"pull_request": { "merged": true, "merged_at": "2026-08-26T22:47:32+02:00" } },
|
|
{ "number": 2, "state": "closed",
|
|
"pull_request": { "merged": false, "merged_at": null } },
|
|
{ "number": 3, "state": "closed" },
|
|
]);
|
|
|
|
// Control: before hoisting, a top-level query cannot tell 1 from 2.
|
|
assert!(items[0].get("merged").is_none(), "control: not hoisted yet");
|
|
assert_eq!(
|
|
items[0]["state"], items[1]["state"],
|
|
"control: `state` is identical"
|
|
);
|
|
|
|
hoist_merge_state(&mut items);
|
|
|
|
assert_eq!(items[0]["merged"], serde_json::json!(true), "merged PR");
|
|
assert_eq!(
|
|
items[1]["merged"],
|
|
serde_json::json!(false),
|
|
"closed unmerged"
|
|
);
|
|
assert_eq!(
|
|
items[0]["merged_at"],
|
|
serde_json::json!("2026-08-26T22:47:32+02:00")
|
|
);
|
|
assert!(items[1]["merged_at"].is_null());
|
|
assert!(
|
|
items[2].get("merged").is_none(),
|
|
"an issue must not gain a `merged` field"
|
|
);
|
|
// Additive: the nested path an existing consumer reads still works.
|
|
assert_eq!(items[0]["pull_request"]["merged"], serde_json::json!(true));
|
|
}
|
|
|
|
/// Non-array input (an error object, say) must not panic or mangle.
|
|
#[test]
|
|
fn hoisting_a_non_array_is_a_no_op() {
|
|
let mut v = serde_json::json!({ "message": "not found" });
|
|
let before = v.clone();
|
|
hoist_merge_state(&mut v);
|
|
assert_eq!(v, before);
|
|
}
|
|
|
|
#[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 blocking_open_count_zero_when_none_blocking() {
|
|
assert_eq!(blocking_open_count(&[]), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_open_count_ignores_closed_ones() {
|
|
let blocking = [dep("closed"), dep("closed")];
|
|
assert_eq!(blocking_open_count(&blocking), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_open_count_counts_only_open_ones() {
|
|
let blocking = [dep("closed"), dep("open"), dep("open")];
|
|
assert_eq!(blocking_open_count(&blocking), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn kind_query_types_match_forgejo_enum() {
|
|
// The forge accepts only `issues` / `pulls` for the `type`
|
|
// parameter (absent = both) — pin the mapping so a clap rename
|
|
// doesn't silently start returning the wrong slice.
|
|
assert_eq!(
|
|
Kind::Issue.query_type(),
|
|
Some(IssueListIssuesQueryType::Issues)
|
|
);
|
|
assert_eq!(Kind::Pr.query_type(), Some(IssueListIssuesQueryType::Pulls));
|
|
assert_eq!(Kind::Both.query_type(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn state_query_states_match_forgejo_enum() {
|
|
assert_eq!(State::Open.query_state(), IssueListIssuesQueryState::Open);
|
|
assert_eq!(
|
|
State::Closed.query_state(),
|
|
IssueListIssuesQueryState::Closed
|
|
);
|
|
assert_eq!(State::All.query_state(), IssueListIssuesQueryState::All);
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_fires_on_a_clamped_page_the_old_heuristic_missed() {
|
|
// The regression this function exists for: `--limit 400` against
|
|
// a forge that clamps to 50, with 187 matches. `count != limit`,
|
|
// so the pre-header code printed nothing and the caller concluded
|
|
// the list was complete.
|
|
let msg = trailer(Some(187), 50, 1, 400).expect("must warn about the other 137");
|
|
assert!(msg.contains("50 of 187"), "{msg}");
|
|
assert!(msg.contains("137 more"), "{msg}");
|
|
assert!(msg.contains("--page 2"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_counts_from_the_served_page_size_not_the_requested_one() {
|
|
// Page 3 of a clamped 50/page walk has shown 150, not 3 * 400.
|
|
let msg = trailer(Some(187), 50, 3, 400).expect("187 > 150");
|
|
assert!(msg.contains("150 of 187"), "{msg}");
|
|
assert!(msg.contains("37 more"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_silent_once_the_total_is_accounted_for() {
|
|
// Last page, short: 2 * 50 + ... -> 60 shown of 60.
|
|
assert_eq!(trailer(Some(60), 10, 6, 10), None);
|
|
// Exactly-full last page: no phantom "page 4".
|
|
assert_eq!(trailer(Some(150), 50, 3, 50), None);
|
|
// Empty page is the end even if the total disagrees (a filter
|
|
// the forge discarded, a concurrent close) — never advertise a
|
|
// next page we just proved is empty.
|
|
assert_eq!(trailer(Some(999), 0, 9, 30), None);
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_falls_back_to_the_full_page_heuristic_without_the_header() {
|
|
let msg = trailer(None, 30, 1, 30).expect("full page, unknown total");
|
|
assert!(msg.contains("30 shown (page 1)"), "{msg}");
|
|
assert!(msg.contains("--page 2"), "{msg}");
|
|
// Short page with no header: the end, as far as we can tell.
|
|
assert_eq!(trailer(None, 12, 1, 30), None);
|
|
}
|
|
}
|