ticket: update list query semantics

This commit is contained in:
2026-07-20 15:45:53 +09:00
parent edc495ac01
commit 0a5270a2cc
6 changed files with 324 additions and 100 deletions
+68 -27
View File
@@ -11,7 +11,7 @@ use ticket::config::{
};
use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary,
TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState,
};
@@ -45,15 +45,11 @@ pub struct CreateOptions {
pub title: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListState {
Planning,
Ready,
Queued,
InProgress,
Done,
Closed,
Active,
All,
States(Vec<TicketWorkflowState>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -381,13 +377,9 @@ fn list(
options: ListOptions,
) -> Result<TicketCliOutput, TicketCliError> {
let filter = match options.state {
ListState::Planning => TicketFilter::state(TicketWorkflowState::Planning),
ListState::Ready => TicketFilter::state(TicketWorkflowState::Ready),
ListState::Queued => TicketFilter::state(TicketWorkflowState::Queued),
ListState::InProgress => TicketFilter::state(TicketWorkflowState::InProgress),
ListState::Done => TicketFilter::state(TicketWorkflowState::Done),
ListState::Closed => TicketFilter::state(TicketWorkflowState::Closed),
ListState::All => TicketFilter::all(),
ListState::Active => TicketListQuery::active(),
ListState::All => TicketListQuery::all(),
ListState::States(states) => TicketListQuery::states(states),
};
let tickets = backend.list(filter)?;
let count = tickets.len();
@@ -750,7 +742,7 @@ fn parse_create(args: &[String]) -> Result<CreateOptions, TicketCliError> {
}
fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
let mut state = ListState::All;
let mut state = ListState::Active;
let mut limit = None;
let mut i = 0;
while i < args.len() {
@@ -1042,17 +1034,42 @@ fn option_with_value(
Ok(None)
}
fn parse_list_state(value: &str) -> Result<ListState, TicketCliError> {
match value {
"planning" => Ok(ListState::Planning),
"ready" => Ok(ListState::Ready),
"queued" => Ok(ListState::Queued),
"inprogress" => Ok(ListState::InProgress),
"done" => Ok(ListState::Done),
"closed" => Ok(ListState::Closed),
"all" => Ok(ListState::All),
_ => Err(TicketCliError::new(format!("invalid state: {value}"))),
fn parse_list_state(raw: &str) -> Result<ListState, TicketCliError> {
let tokens = raw
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
.collect::<Vec<_>>();
if tokens.is_empty() {
return Err(TicketCliError::new("--state must not be empty"));
}
if tokens.len() == 1 {
match tokens[0] {
"active" => return Ok(ListState::Active),
"all" => return Ok(ListState::All),
_ => {}
}
} else if tokens
.iter()
.any(|token| *token == "active" || *token == "all")
{
return Err(TicketCliError::new(
"--state active/all cannot be mixed with workflow states",
));
}
let mut states = Vec::new();
for token in tokens {
let state = TicketWorkflowState::parse(token).ok_or_else(|| {
TicketCliError::new(format!(
"invalid state: {token}; expected active, all, planning, ready, queued, inprogress, done, closed"
))
})?;
if !states.contains(&state) {
states.push(state);
}
}
Ok(ListState::States(states))
}
fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
@@ -1148,7 +1165,7 @@ fn default_author() -> String {
}
fn help_text() -> &'static str {
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
}
#[cfg(test)]
@@ -1533,6 +1550,30 @@ mod tests {
assert!(err.to_string().contains("use `yoi ticket close"));
}
#[test]
fn ticket_cli_list_defaults_to_active_and_accepts_multi_state_filter() {
let default = parse_ticket_args(&args(&["list"])).unwrap();
match default {
TicketCommand::List(options) => assert_eq!(options.state, ListState::Active),
other => panic!("unexpected command: {other:?}"),
}
let explicit = parse_ticket_args(&args(&["list", "--state", "planning,closed"])).unwrap();
match explicit {
TicketCommand::List(options) => assert_eq!(
options.state,
ListState::States(vec![
TicketWorkflowState::Planning,
TicketWorkflowState::Closed
])
),
other => panic!("unexpected command: {other:?}"),
}
let mixed = parse_ticket_args(&args(&["list", "--state", "active,planning"])).unwrap_err();
assert!(mixed.to_string().contains("cannot be mixed"));
}
#[test]
fn ticket_cli_help_lists_required_commands() {
let help = parse_ticket_args(&args(&["--help"])).unwrap();