The third bullet of the issue's ask, dropped in the first pass and caught in review: a filter value that does not resolve is a near-miss far more often than an invention, and an error that only lists all 18 available names makes the reader do the diff by eye -- on the one occasion they already know they mistyped something. Thresholded rather than always suggesting the minimum-distance candidate: a wrong suggestion is worse than none, because it invites a second failed attempt at a name that was never there. The bound scales with the needle (a third of its length, capped at 3), so a short name does not match half the repo and a long one still tolerates a typo or two, and an unrelated word falls back to the full list. Tie-break is on length then alphabetical, so the suggestion does not depend on the order the forge happened to return its labels in.
233 lines
7.8 KiB
Rust
233 lines
7.8 KiB
Rust
//! `milestone list|create|close` — manage milestones. Default action:
|
|
//! list.
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use clap::{Args as ClapArgs, Subcommand};
|
|
use forgejo_api::structs::{
|
|
CreateMilestoneOption, EditMilestoneOption, IssueGetMilestonesListQuery, Milestone,
|
|
};
|
|
use serde_json::{Value, json};
|
|
use time::OffsetDateTime;
|
|
use time::format_description::well_known::Rfc3339;
|
|
|
|
use crate::client::{Client, index};
|
|
use crate::verbs::{print_json, rfc3339};
|
|
|
|
#[derive(ClapArgs)]
|
|
pub struct Args {
|
|
#[command(subcommand)]
|
|
action: Option<Action>,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Action {
|
|
/// List open milestones as JSON.
|
|
List,
|
|
/// Create a milestone, print {id,title}.
|
|
Create {
|
|
/// Milestone title.
|
|
#[arg(long)]
|
|
title: String,
|
|
/// Description.
|
|
#[arg(long)]
|
|
desc: Option<String>,
|
|
/// Due date YYYY-MM-DD.
|
|
#[arg(long)]
|
|
due: Option<String>,
|
|
},
|
|
/// Close a milestone by id.
|
|
Close {
|
|
/// Milestone id.
|
|
id: u64,
|
|
},
|
|
}
|
|
|
|
/// 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: {}",
|
|
crate::verbs::with_suggestions(&unresolved, &available),
|
|
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 milestones = repo_milestones(client, "open")?;
|
|
let trimmed: Vec<Value> = milestones
|
|
.iter()
|
|
.map(|m| {
|
|
json!({
|
|
"id": m.id,
|
|
"title": m.title,
|
|
"open_issues": m.open_issues,
|
|
"closed_issues": m.closed_issues,
|
|
"due_on": rfc3339(m.due_on),
|
|
"description": m.description,
|
|
})
|
|
})
|
|
.collect();
|
|
print_json(&Value::Array(trimmed))
|
|
}
|
|
Action::Create { title, desc, due } => {
|
|
let due_on = due
|
|
.filter(|s| !s.is_empty())
|
|
.map(|d| {
|
|
OffsetDateTime::parse(&format!("{d}T00:00:00Z"), &Rfc3339)
|
|
.with_context(|| format!("milestone create: bad --due date {d:?}"))
|
|
})
|
|
.transpose()?;
|
|
let payload = CreateMilestoneOption {
|
|
description: desc.filter(|s| !s.is_empty()),
|
|
due_on,
|
|
state: None,
|
|
title: Some(title),
|
|
};
|
|
let resp = client
|
|
.api()
|
|
.issue_create_milestone(owner, name, payload)
|
|
.send()?;
|
|
print_json(&json!({
|
|
"id": resp.id,
|
|
"title": resp.title,
|
|
}))
|
|
}
|
|
Action::Close { id } => {
|
|
let payload = EditMilestoneOption {
|
|
description: None,
|
|
due_on: None,
|
|
state: Some("closed".to_owned()),
|
|
title: None,
|
|
};
|
|
let resp = client
|
|
.api()
|
|
.issue_edit_milestone(owner, name, index(id)?, payload)
|
|
.send()?;
|
|
print_json(&json!({
|
|
"id": resp.id,
|
|
"title": resp.title,
|
|
"state": resp.state,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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)"));
|
|
}
|
|
}
|