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:
parent
433b294099
commit
0aa9a854bc
4 changed files with 206 additions and 36 deletions
|
|
@ -1,10 +1,10 @@
|
|||
//! `milestone list|create|close` — manage milestones. Default action:
|
||||
//! list.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Args as ClapArgs, Subcommand};
|
||||
use forgejo_api::structs::{
|
||||
CreateMilestoneOption, EditMilestoneOption, IssueGetMilestonesListQuery,
|
||||
CreateMilestoneOption, EditMilestoneOption, IssueGetMilestonesListQuery, Milestone,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
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<()> {
|
||||
let (owner, name) = client.owner_repo()?;
|
||||
match args.action.unwrap_or(Action::List) {
|
||||
Action::List => {
|
||||
let query = IssueGetMilestonesListQuery {
|
||||
state: Some("open".to_owned()),
|
||||
name: None,
|
||||
};
|
||||
let (_, milestones) = client
|
||||
.api()
|
||||
.issue_get_milestones_list(owner, name, query)
|
||||
.page_size(50)
|
||||
.send()?;
|
||||
let milestones = repo_milestones(client, "open")?;
|
||||
let trimmed: Vec<Value> = milestones
|
||||
.iter()
|
||||
.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)"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue