hyperhive/hive-forge/src/verbs/list.rs
atlas 0aa9a854bc fix(forge): validate list's label + milestone filters, and paginate both
A filter value the forge cannot resolve is DISCARDED, not rejected, so a
typo does not narrow the result set -- it returns the unfiltered one.
That does not 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.

`list` now resolves both before querying. Labels reuse the write side's
resolver; the ids are discarded because this endpoint filters by name, so
resolution here is a spell-check rather than a lookup -- reusing it keeps
the message identical to the one the write side has always produced.
Milestones accept a title or an id and are checked against the ALL-state
set: filtering on a closed milestone is a normal query, and validating
against open-only would reject exactly the retrospective ones.

Both fetchers paginate. `repo_labels` asked for one page of 100 and
treated it as the population -- the inverse of the trailer bug, same
root: a valid label past the cut fails to resolve, and the error then
prints an "available labels" list that is itself truncated, so the
message argues for the typo.

`--assignee` / `--author` stay unvalidated on purpose: someone who has
left still legitimately appears on old issues, so a login that is not a
current member is not necessarily a typo.

Also drops the docs paragraph claiming unknown labels are silently
dropped on the write side; that has not been true since the resolver
landed.
2026-08-05 22:06:12 +02:00

320 lines
13 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.
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::{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 items = serde_json::to_value(issues)?;
if client.json_mode() {
return print_json(&items);
}
for item in items.as_array().into_iter().flatten() {
print_row(item);
}
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.
/// 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) {
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 { " " };
println!("#{number:>4} {kind} [{author}] {title}");
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
}