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

@ -276,20 +276,25 @@ to discover valid label names before triaging or to audit the label set.
- Do NOT use raw `curl` for forge access -- the CLI handles auth, - Do NOT use raw `curl` for forge access -- the CLI handles auth,
error checking, and output formatting. error checking, and output formatting.
- `issue-create --label <name>` / `pr-create --label <name>` are - `issue-create --label <name>` / `pr-create --label <name>` are
repeatable and take the same spelling `labels <n> add` does. Unknown repeatable and take the same spelling `labels <n> add` does. **An
names are silently dropped (matching `labels add`'s existing unknown name is an error, not a silent drop** — the command fails
behavior) rather than erroring, so a typo just means the label listing the names that didn't resolve plus every label the repo has,
doesn't land — check `hive-forge repo-labels` if one's missing. On so it's fixable from the message without a second call. On
`pr-create --agit`, labels are applied as a follow-up call once the `pr-create --agit`, labels are applied as a follow-up call once the
PR number is parsed back out of the push output (the AGit push PR number is parsed back out of the push output (the AGit push
itself has no label field), so they're silently skipped if that itself has no label field), so they're silently skipped if that
parse fails — same fallback as the deferred multi-line body. parse fails — same fallback as the deferred multi-line body.
- `list --milestone <name>` takes a milestone **name or id**, is - `list --label <name>` / `list --milestone <name>` are repeatable and
repeatable, and the forge *discards* one it doesn't recognise. So a validated the same way, for a sharper reason: the forge **discards** a
typo returns the **unfiltered** list rather than an empty one — the filter value it doesn't recognise, so a typo returns the
failure looks like "this milestone contains everything", not like an **unfiltered** list rather than an empty one. That doesn't waste a
error. Confirm the spelling with `hive-forge milestone`. (Same query, it inverts the answer — "is anything open in this milestone"
silent-discard shape as unknown labels above.) comes back as every open issue. `--milestone` takes a title or an id,
and closed milestones count as valid (filtering on a shipped one is a
normal query).
- ⚠️ `--assignee` / `--author` are deliberately **not** validated: someone
who has left still legitimately appears on old issues, so a login that
isn't a current member is not necessarily a typo.
- `list --limit N` is a *request*: the forge clamps page size to its own - `list --limit N` is a *request*: the forge clamps page size to its own
`api.MAX_RESPONSE_ITEMS` (50 by default), so `--limit 400` returns at `api.MAX_RESPONSE_ITEMS` (50 by default), so `--limit 400` returns at
most 50 rows. The stderr trailer reports the real total from the most 50 rows. The stderr trailer reports the real total from the

View file

@ -90,18 +90,38 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
Ok(()) Ok(())
} }
/// First page (100) of the repo's label set, for name → id resolution. /// The repo's whole label set, for name → id resolution. `pub(crate)` so
/// `pub(crate)` so other creation verbs (`issue-create`, `pr-create`) can /// other verbs (`issue-create`, `pr-create`, `list`) can resolve a label
/// resolve a `--label` name to the id the create-payload structs need /// name without duplicating the lookup.
/// 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>> { 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 (owner, name) = client.owner_repo()?;
let (_, labels) = client let mut all = Vec::new();
.api() for page in 1..=MAX_PAGES {
.issue_list_labels(owner, name, IssueListLabelsQuery::default()) let (_, batch) = client
.page_size(100) .api()
.send()?; .issue_list_labels(owner, name, IssueListLabelsQuery::default())
Ok(labels) .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 /// Resolve label names to ids, hard-erroring if any name doesn't match an

View file

@ -24,7 +24,7 @@ use forgejo_api::structs::{
use serde_json::Value; use serde_json::Value;
use crate::client::Client; use crate::client::Client;
use crate::verbs::print_json; use crate::verbs::{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`
@ -89,12 +89,15 @@ pub struct Args {
#[arg(long)] #[arg(long)]
mention: Option<String>, mention: Option<String>,
/// Filter to items carrying any of these label names. Repeatable. /// 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")] #[arg(long = "label")]
labels: Vec<String>, labels: Vec<String>,
/// Filter to items in any of these milestones, by name or id. /// Filter to items in any of these milestones, by title or id.
/// Repeatable. A name that doesn't exist is discarded by the forge /// Repeatable. Validated client-side against the repo's milestones
/// rather than rejected — so a typo returns the UNFILTERED list, not /// (closed ones included), since the forge would silently discard a
/// an empty one. Check the spelling against `milestone list`. /// name it can't resolve and return the UNFILTERED list.
#[arg(long = "milestone")] #[arg(long = "milestone")]
milestones: Vec<String>, milestones: Vec<String>,
/// Full-text search over title AND body, server-side. Composes with /// Full-text search over title AND body, server-side. Composes with
@ -117,6 +120,29 @@ pub struct Args {
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let (owner, name) = client.owner_repo()?; 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 { let query = IssueListIssuesQuery {
state: Some(args.state.query_state()), state: Some(args.state.query_state()),
// The forge parses `labels` as a comma-separated list of names. // The forge parses `labels` as a comma-separated list of names.

View file

@ -1,10 +1,10 @@
//! `milestone list|create|close` — manage milestones. Default action: //! `milestone list|create|close` — manage milestones. Default action:
//! list. //! list.
use anyhow::{Context, Result}; use anyhow::{Context, Result, bail};
use clap::{Args as ClapArgs, Subcommand}; use clap::{Args as ClapArgs, Subcommand};
use forgejo_api::structs::{ use forgejo_api::structs::{
CreateMilestoneOption, EditMilestoneOption, IssueGetMilestonesListQuery, CreateMilestoneOption, EditMilestoneOption, IssueGetMilestonesListQuery, Milestone,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use time::OffsetDateTime; use time::OffsetDateTime;
@ -42,19 +42,80 @@ enum Action {
}, },
} }
/// The repo's milestones in `state` (`open` / `closed` / `all`), all
/// pages. `pub(crate)` so `list` can validate a `--milestone` filter
/// against them.
///
/// Paginated for the reason `labels::repo_labels` spells out: a page size
/// with no page loop silently truncates the population, and a validator
/// reading a truncated set rejects legitimate input.
pub(crate) fn repo_milestones(client: &Client, state: &str) -> Result<Vec<Milestone>> {
/// Generous cap so a misbehaving server can't spin us forever.
const MAX_PAGES: u32 = 50;
const PAGE: u32 = 50;
let (owner, name) = client.owner_repo()?;
let mut all = Vec::new();
for page in 1..=MAX_PAGES {
let query = IssueGetMilestonesListQuery {
state: Some(state.to_owned()),
name: None,
};
let (_, batch) = client
.api()
.issue_get_milestones_list(owner, name, query)
.page(page)
.page_size(PAGE)
.send()?;
let short = batch.len() < PAGE as usize;
all.extend(batch);
if short {
break;
}
}
Ok(all)
}
/// Check every `--milestone` filter value names a real milestone,
/// erroring with the ones that don't plus what is available.
///
/// Accepts a title **or** a numeric id, because Forgejo's filter does.
///
/// ⚠️ Callers must pass the `all`-state set, not the open one: filtering
/// on a *closed* milestone is a normal query ("what shipped in v1"), and
/// validating against open-only would reject exactly the retrospective
/// queries that need it most. Same reasoning that keeps `--assignee` and
/// `--author` unvalidated — a name being no longer current is not a typo.
pub(crate) fn ensure_filters_resolve(all: &[Milestone], values: &[String]) -> Result<()> {
let unresolved: Vec<&str> = values
.iter()
.filter(|v| {
!all.iter().any(|m| {
m.title.as_deref() == Some(v.as_str())
|| v.parse::<i64>().is_ok_and(|id| m.id == Some(id))
})
})
.map(String::as_str)
.collect();
if !unresolved.is_empty() {
let available: Vec<&str> = all.iter().filter_map(|m| m.title.as_deref()).collect();
bail!(
"unresolved milestone(s): {} — available milestones: {}",
unresolved.join(", "),
if available.is_empty() {
"(none)".to_owned()
} else {
available.join(", ")
}
);
}
Ok(())
}
pub fn run(client: &Client, args: Args) -> Result<()> { pub fn run(client: &Client, args: Args) -> Result<()> {
let (owner, name) = client.owner_repo()?; let (owner, name) = client.owner_repo()?;
match args.action.unwrap_or(Action::List) { match args.action.unwrap_or(Action::List) {
Action::List => { Action::List => {
let query = IssueGetMilestonesListQuery { let milestones = repo_milestones(client, "open")?;
state: Some("open".to_owned()),
name: None,
};
let (_, milestones) = client
.api()
.issue_get_milestones_list(owner, name, query)
.page_size(50)
.send()?;
let trimmed: Vec<Value> = milestones let trimmed: Vec<Value> = milestones
.iter() .iter()
.map(|m| { .map(|m| {
@ -112,3 +173,61 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::{Milestone, ensure_filters_resolve};
/// Struct literal rather than `serde_json::from_value`: the JSON
/// fixture compiled and then failed at *runtime* on a required field
/// (`closed_at`), which is a worse trade than listing the fields —
/// this way a field the upstream crate adds is a compile error, and
/// the neighbouring `labels.rs` fixture already does it this way.
fn milestone(id: i64, title: &str) -> Milestone {
Milestone {
closed_at: None,
closed_issues: None,
created_at: None,
description: None,
due_on: None,
id: Some(id),
open_issues: None,
state: None,
title: Some(title.to_owned()),
updated_at: None,
}
}
#[test]
fn titles_and_ids_both_resolve() {
let all = vec![milestone(1, "v1"), milestone(2, "v2")];
ensure_filters_resolve(&all, &["v2".to_owned(), "1".to_owned()]).unwrap();
}
#[test]
fn unresolved_value_errors_listing_the_typo_and_available_titles() {
let all = vec![milestone(1, "v1"), milestone(2, "v2")];
let err = ensure_filters_resolve(&all, &["v3".to_owned()]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("v3"), "missing the typo: {msg}");
assert!(
msg.contains("v1") && msg.contains("v2"),
"missing what is available: {msg}"
);
}
#[test]
fn a_numeric_value_is_an_id_not_a_title() {
// "2" must not match the milestone *titled* "2" by accident of
// parsing, nor resolve because some milestone exists — an id
// filter that silently matches nothing is the whole bug.
let all = vec![milestone(1, "v1")];
assert!(ensure_filters_resolve(&all, &["2".to_owned()]).is_err());
}
#[test]
fn unresolved_on_a_repo_with_no_milestones_says_none() {
let err = ensure_filters_resolve(&[], &["v1".to_owned()]).unwrap_err();
assert!(err.to_string().contains("(none)"));
}
}