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.
This commit is contained in:
atlas 2026-08-05 22:06:12 +02:00
commit 0aa9a854bc
4 changed files with 206 additions and 36 deletions

View file

@ -90,18 +90,38 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
Ok(())
}
/// First page (100) of the repo's label set, for name → id resolution.
/// `pub(crate)` so other creation verbs (`issue-create`, `pr-create`) can
/// resolve a `--label` name to the id the create-payload structs need
/// without duplicating the lookup.
/// The repo's whole label set, for name → id resolution. `pub(crate)` so
/// other verbs (`issue-create`, `pr-create`, `list`) can resolve a label
/// name without duplicating the lookup.
///
/// Paginated, where this used to ask for one page of 100 — **a page size
/// with no page loop is a lie the size of the page.** Truncation here is
/// worse than it looks: it doesn't drop a result, it makes a *valid*
/// label past the cut fail to resolve, and then prints an "available
/// labels" list that is itself incomplete, so the error argues for the
/// typo. Same root as the `list` trailer bug (assuming one request
/// returns the whole population), opposite direction — that one was a
/// false positive, this is a false negative.
pub(crate) fn repo_labels(client: &Client) -> Result<Vec<Label>> {
/// Generous cap so a misbehaving server can't spin us forever.
const MAX_PAGES: u32 = 50;
const PAGE: u32 = 100;
let (owner, name) = client.owner_repo()?;
let (_, labels) = client
.api()
.issue_list_labels(owner, name, IssueListLabelsQuery::default())
.page_size(100)
.send()?;
Ok(labels)
let mut all = Vec::new();
for page in 1..=MAX_PAGES {
let (_, batch) = client
.api()
.issue_list_labels(owner, name, IssueListLabelsQuery::default())
.page(page)
.page_size(PAGE)
.send()?;
let short = batch.len() < PAGE as usize;
all.extend(batch);
if short {
break;
}
}
Ok(all)
}
/// Resolve label names to ids, hard-erroring if any name doesn't match an