diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 36dcd375..a68dd4d4 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -510,18 +510,75 @@ impl NewTicket { } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct TicketFilter { - pub state: Option, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketStateSelector { + /// All non-closed workflow states: planning, ready, queued, inprogress, and done. + Active, + /// Every workflow state, including closed. + All, + /// An explicit set of workflow states. + States(BTreeSet), } -impl TicketFilter { +impl Default for TicketStateSelector { + fn default() -> Self { + Self::Active + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketListQuery { + pub state: TicketStateSelector, +} + +impl Default for TicketListQuery { + fn default() -> Self { + Self::active() + } +} + +impl TicketListQuery { + pub fn active() -> Self { + Self { + state: TicketStateSelector::Active, + } + } + pub fn all() -> Self { - Self { state: None } + Self { + state: TicketStateSelector::All, + } } pub fn state(state: TicketWorkflowState) -> Self { - Self { state: Some(state) } + Self::states([state]) + } + + pub fn states(states: impl IntoIterator) -> Self { + Self { + state: TicketStateSelector::States(states.into_iter().collect()), + } + } + + pub fn matches_state(&self, state: TicketWorkflowState) -> bool { + match &self.state { + TicketStateSelector::Active => state != TicketWorkflowState::Closed, + TicketStateSelector::All => true, + TicketStateSelector::States(states) => states.contains(&state), + } + } + + pub fn state_filter_label(&self) -> String { + match &self.state { + TicketStateSelector::Active => "active".to_string(), + TicketStateSelector::All => "all".to_string(), + TicketStateSelector::States(states) => states + .iter() + .map(|state| state.as_str()) + .collect::>() + .join(","), + } } } @@ -873,7 +930,7 @@ impl TicketDoctorReport { pub trait TicketBackend { fn default_intake_ready_state_change_body(&self, from: &str) -> String; - fn list(&self, filter: TicketFilter) -> Result>; + fn list(&self, filter: TicketListQuery) -> Result>; fn show(&self, id: TicketIdOrSlug) -> Result; fn create(&self, input: NewTicket) -> Result; fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; @@ -926,7 +983,7 @@ pub enum TicketBackendOperation { from: String, }, List { - filter: TicketFilter, + filter: TicketListQuery, }, Show { id: TicketIdOrSlug, @@ -1143,10 +1200,10 @@ impl LocalTicketBackend { } } - pub fn list_partial(&self, filter: TicketFilter) -> Result { + pub fn list_partial(&self, filter: TicketListQuery) -> Result { let mut output = TicketPartialList::default(); let mut invalid_seen = BTreeSet::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { let item = dir.join("item.md"); if !item.exists() { continue; @@ -1155,10 +1212,7 @@ impl LocalTicketBackend { .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) { Ok(meta) => { - if filter - .state - .is_some_and(|state| meta.workflow_state != state) - { + if !filter.matches_state(meta.workflow_state) { continue; } output.tickets.push(ticket_summary_from_meta(meta)); @@ -1255,7 +1309,7 @@ impl LocalTicketBackend { } } - fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result> { + fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result> { let mut dirs = Vec::new(); if !self.root.exists() { return Ok(dirs); @@ -1275,10 +1329,10 @@ impl LocalTicketBackend { if !item.is_file() { continue; } - if let Some(state) = filter.state { + if !matches!(filter.state, TicketStateSelector::All) { let parsed = read_item_file(&item)?; let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?; - if meta.workflow_state != state { + if !filter.matches_state(meta.workflow_state) { continue; } } @@ -1495,7 +1549,7 @@ impl LocalTicketBackend { fn all_ticket_relation_records(&self) -> Result> { let mut relations = Vec::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { relations.extend(self.read_ticket_relations_for_dir(&dir)?); } sort_ticket_relations(&mut relations); @@ -1508,7 +1562,7 @@ impl LocalTicketBackend { invalid_seen: &mut BTreeSet, ) -> Result> { let mut relations = Vec::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { match self.read_ticket_relations_for_dir(&dir) { Ok(records) => relations.extend(records), Err(error) => { @@ -1539,7 +1593,7 @@ impl LocalTicketBackend { fn ticket_state_index(&self) -> Result> { let mut states = HashMap::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { let item = dir.join("item.md"); let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; states.insert(meta.id, meta.workflow_state); @@ -1553,7 +1607,7 @@ impl LocalTicketBackend { invalid_seen: &mut BTreeSet, ) -> Result> { let mut states = HashMap::new(); - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { let item = dir.join("item.md"); match read_item_file(&item) .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) @@ -1589,7 +1643,7 @@ impl TicketBackend for LocalTicketBackend { self.default_intake_ready_state_change_body(from) } - fn list(&self, filter: TicketFilter) -> Result> { + fn list(&self, filter: TicketListQuery) -> Result> { let mut tickets = Vec::new(); for dir in self.iter_ticket_dirs(filter)? { let item = dir.join("item.md"); @@ -2088,7 +2142,7 @@ impl TicketBackend for LocalTicketBackend { let dir = self.find_ticket_dir(&ticket)?; records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); } else { - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); } } @@ -2117,7 +2171,7 @@ impl TicketBackend for LocalTicketBackend { } } - for dir in self.iter_ticket_dirs(TicketFilter::all())? { + for dir in self.iter_ticket_dirs(TicketListQuery::all())? { let ticket_id = match ticket_id_from_dir(&dir) { Ok(id) => id, Err(err) => { @@ -3975,6 +4029,57 @@ state: planning assert!(err.contains("invalid YAML frontmatter"), "{err}"); } + #[test] + fn list_query_defaults_to_active_and_supports_all_or_explicit_states() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); + let mut ready_input = NewTicket::new("Ready Ticket"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let ready = backend.create(ready_input).unwrap(); + let mut closed_input = NewTicket::new("Closed Ticket"); + closed_input.workflow_state = Some(TicketWorkflowState::Closed); + let closed = backend.create(closed_input).unwrap(); + + let active = backend.list(TicketListQuery::default()).unwrap(); + let active_ids = active + .iter() + .map(|ticket| ticket.id.as_str()) + .collect::>(); + assert!(active_ids.contains(&planning.id.as_str())); + assert!(active_ids.contains(&ready.id.as_str())); + assert!(!active_ids.contains(&closed.id.as_str())); + + let all = backend.list(TicketListQuery::all()).unwrap(); + let all_ids = all + .iter() + .map(|ticket| ticket.id.as_str()) + .collect::>(); + assert!(all_ids.contains(&planning.id.as_str())); + assert!(all_ids.contains(&ready.id.as_str())); + assert!(all_ids.contains(&closed.id.as_str())); + + let ready_only = backend + .list(TicketListQuery::state(TicketWorkflowState::Ready)) + .unwrap(); + assert_eq!(ready_only.len(), 1); + assert_eq!(ready_only[0].id, ready.id); + + let planning_or_closed = backend + .list(TicketListQuery::states([ + TicketWorkflowState::Planning, + TicketWorkflowState::Closed, + ])) + .unwrap(); + let explicit_ids = planning_or_closed + .iter() + .map(|ticket| ticket.id.as_str()) + .collect::>(); + assert!(explicit_ids.contains(&planning.id.as_str())); + assert!(explicit_ids.contains(&closed.id.as_str())); + assert!(!explicit_ids.contains(&ready.id.as_str())); + } + #[test] fn create_writes_local_ticket_layout() { let tmp = TempDir::new().unwrap(); @@ -4036,9 +4141,9 @@ state: planning ) .unwrap(); - assert!(backend.list(TicketFilter::all()).is_err()); + assert!(backend.list(TicketListQuery::all()).is_err()); - let partial = backend.list_partial(TicketFilter::all()).unwrap(); + let partial = backend.list_partial(TicketListQuery::all()).unwrap(); assert_eq!(partial.tickets.len(), 1); assert_eq!(partial.tickets[0].id, valid.id); assert_eq!(partial.invalid_records.len(), 1); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index c25540f3..33dea587 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -97,8 +97,8 @@ const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed T Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \ backend assigns the id and writes the local Ticket file layout under the configured backend root."; const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \ -lightweight bounded overview for selection only. Filter by state (`planning`, `ready`, `queued`, \ -`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \ +lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \ +state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \ routing, closing, planning, or implementation decisions."; const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \ typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \ @@ -214,7 +214,7 @@ impl TicketBackend for TicketToolBackend { self.backend.default_intake_ready_state_change_body(from) } - fn list(&self, filter: crate::TicketFilter) -> TicketResult> { + fn list(&self, filter: crate::TicketListQuery) -> TicketResult> { self.backend.list(filter) } @@ -375,9 +375,10 @@ impl TicketWorkflowStateParam { } } -#[derive(Debug, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] enum TicketListStateParam { + Active, Planning, Ready, Queued, @@ -388,47 +389,62 @@ enum TicketListStateParam { } impl TicketListStateParam { - fn as_filter(self) -> (crate::TicketFilter, &'static str) { + fn as_state(self) -> Option { match self { - Self::Planning => ( - crate::TicketFilter::state(TicketWorkflowState::Planning), - "planning", - ), - Self::Ready => ( - crate::TicketFilter::state(TicketWorkflowState::Ready), - "ready", - ), - Self::Queued => ( - crate::TicketFilter::state(TicketWorkflowState::Queued), - "queued", - ), - Self::Inprogress => ( - crate::TicketFilter::state(TicketWorkflowState::InProgress), - "inprogress", - ), - Self::Done => ( - crate::TicketFilter::state(TicketWorkflowState::Done), - "done", - ), - Self::Closed => ( - crate::TicketFilter::state(TicketWorkflowState::Closed), - "closed", - ), - Self::All => (crate::TicketFilter::all(), "all"), + Self::Planning => Some(TicketWorkflowState::Planning), + Self::Ready => Some(TicketWorkflowState::Ready), + Self::Queued => Some(TicketWorkflowState::Queued), + Self::Inprogress => Some(TicketWorkflowState::InProgress), + Self::Done => Some(TicketWorkflowState::Done), + Self::Closed => Some(TicketWorkflowState::Closed), + Self::Active | Self::All => None, } } } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketListParams { - /// State filter. Defaults to all Tickets. + /// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets. #[serde(default)] state: Option, + /// Explicit workflow-state filter list. Cannot be combined with `state`. + #[serde(default)] + states: Option>, /// Maximum number of summaries to return. Defaults to 50, max 100. #[serde(default)] limit: Option, } +impl TicketListParams { + fn into_query(self) -> Result<(crate::TicketListQuery, String, Option), TicketError> { + let query = if let Some(states) = self.states { + if self.state.is_some() { + return Err(TicketError::Conflict( + "TicketList accepts either `state` or `states`, not both".to_string(), + )); + } + if states.is_empty() { + return Err(TicketError::Conflict( + "TicketList `states` must include at least one workflow state".to_string(), + )); + } + crate::TicketListQuery::states(states.into_iter().map(|state| state.into_state())) + } else { + match self.state.unwrap_or(TicketListStateParam::Active) { + TicketListStateParam::Active => crate::TicketListQuery::active(), + TicketListStateParam::All => crate::TicketListQuery::all(), + state => crate::TicketListQuery::state( + state + .as_state() + .expect("workflow state list param maps to TicketWorkflowState"), + ), + } + }; + let label = query.state_filter_label(); + Ok((query, label, self.limit)) + } +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketShowParams { /// Ticket id. Exactly one of `id` or `query` must be provided. @@ -825,9 +841,10 @@ impl Tool for TicketListTool { _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { let params: TicketListParams = parse_input("TicketList", input_json)?; - let state = params.state.unwrap_or(TicketListStateParam::All); - let (filter, state_filter) = state.as_filter(); - let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); + let (filter, state_filter, params_limit) = params + .into_query() + .map_err(|error| backend_error("TicketList", error))?; + let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); let tickets = self .backend .list(filter) @@ -1825,6 +1842,16 @@ mod tests { .unwrap(); } + let active = list + .execute(&json!({}).to_string(), Default::default()) + .await + .unwrap(); + let active_json: Value = serde_json::from_str(&active.content.unwrap()).unwrap(); + assert_eq!(active_json["state_filter"], "active"); + assert_eq!(active_json["count"].as_u64(), Some(3)); + assert_eq!(active_json["returned"].as_u64(), Some(3)); + assert_eq!(active_json["truncated"].as_bool(), Some(false)); + let all = list .execute(&json!({ "state": "all" }).to_string(), Default::default()) .await @@ -1857,6 +1884,53 @@ mod tests { assert_eq!(closed_json["truncated"].as_bool(), Some(true)); } + #[tokio::test] + async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let list = tool_by_name(backend.clone(), "TicketList"); + let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); + let mut ready_input = NewTicket::new("Ready Ticket"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let ready = backend.create(ready_input).unwrap(); + let mut closed_input = NewTicket::new("Closed Ticket"); + closed_input.workflow_state = Some(TicketWorkflowState::Closed); + let closed = backend.create(closed_input).unwrap(); + + let listed = list + .execute( + &json!({ "states": ["planning", "closed"] }).to_string(), + Default::default(), + ) + .await + .unwrap(); + let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap(); + assert_eq!(listed_json["state_filter"], "planning,closed"); + assert_eq!(listed_json["count"].as_u64(), Some(2)); + let listed_ids = listed_json["tickets"] + .as_array() + .unwrap() + .iter() + .map(|ticket| ticket["id"].as_str().unwrap()) + .collect::>(); + assert!(listed_ids.contains(&planning.id.as_str())); + assert!(listed_ids.contains(&closed.id.as_str())); + assert!(!listed_ids.contains(&ready.id.as_str())); + + let mixed = list + .execute( + &json!({ "state": "active", "states": ["planning"] }).to_string(), + Default::default(), + ) + .await; + assert!(mixed.is_err()); + + let empty = list + .execute(&json!({ "states": [] }).to_string(), Default::default()) + .await; + assert!(empty.is_err()); + } + #[tokio::test] async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { let temp = TempDir::new().unwrap(); @@ -2408,7 +2482,10 @@ mod tests { assert!(!id.contains("escape")); assert!(!temp.path().join("escape").exists()); assert!(temp.path().join("tickets").join(id).is_dir()); - assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1); + assert_eq!( + backend.list(crate::TicketListQuery::all()).unwrap().len(), + 1 + ); } #[test] diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 670cd102..00eb6c5d 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -10,8 +10,9 @@ use ticket::config::{ WORKSPACE_SETTINGS_RELATIVE_PATH, }; use ticket::{ - LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, - TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState, + LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug, + TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary, + TicketWorkflowState, }; use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore}; @@ -693,7 +694,7 @@ fn load_orchestration_ticket_overlay_states( let backend = LocalTicketBackend::new(ticket_root.to_path_buf()) .with_record_language(overlay_config.ticket_record_language()); let partial = backend - .list_partial(TicketFilter::all()) + .list_partial(TicketListQuery::all()) .map_err(|error| error.to_string())?; let mut states = BTreeMap::new(); for summary in partial.tickets { @@ -1092,7 +1093,7 @@ fn build_ticket_rows( registry: &PanelRegistrySnapshot, orchestration_overlay: &BTreeMap, ) -> ticket::Result { - let partial = backend.list_partial(TicketFilter::all())?; + let partial = backend.list_partial(TicketListQuery::all())?; let mut ticket_rows = Vec::new(); let mut invalid_records = partial.invalid_records; for summary in partial.tickets { @@ -2522,7 +2523,7 @@ mod tests { input.workflow_state = Some(TicketWorkflowState::Ready); }); let ticket_id = backend - .list(TicketFilter::all()) + .list(TicketListQuery::all()) .unwrap() .into_iter() .find(|ticket| ticket.title == "Ticket With Intake") @@ -2619,7 +2620,7 @@ mod tests { write_ticket_config(temp.path()); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); create_ticket(&backend, "Claimed Planning", |_| {}); - let summary = backend.list(TicketFilter::all()).unwrap().remove(0); + let summary = backend.list(TicketListQuery::all()).unwrap().remove(0); let store = PanelRegistryStore::from_root(temp.path().join("local-registry")); store .claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake") diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 6fd4292e..168b640b 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -10,9 +10,9 @@ use ticket::{ LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation, - TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketFilter, TicketIdOrSlug, - TicketIntakeSummary, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, - TicketReview, TicketStateChange, TicketSummary, + TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug, + TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind, + TicketRelationView, TicketReview, TicketStateChange, TicketSummary, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, tool::{ TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, @@ -400,7 +400,7 @@ impl TicketBackend for WorkspaceHttpTicketBackend { } } - fn list(&self, filter: TicketFilter) -> TicketResult> { + fn list(&self, filter: TicketListQuery) -> TicketResult> { expect_ticket_result!( self.invoke(TicketBackendOperation::List { filter }), TicketBackendOperationResult::Tickets diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 204c8f08..800bc7e6 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use project_record::validate_record_id; use serde::{Deserialize, Serialize}; use ticket::config::TicketConfig; -use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; +use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery}; use crate::{Error, Result}; @@ -35,7 +35,7 @@ impl LocalProjectRecordReader { } pub fn list_tickets(&self, limit: usize) -> Result> { - let partial = self.ticket_backend.list_partial(TicketFilter::all())?; + let partial = self.ticket_backend.list_partial(TicketListQuery::all())?; let mut items = partial .tickets .into_iter() diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index b345c30b..f3a26475 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -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), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -381,13 +377,9 @@ fn list( options: ListOptions, ) -> Result { 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 { } fn parse_list(args: &[String]) -> Result { - 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 { - 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 { + let tokens = raw + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()) + .collect::>(); + 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 { @@ -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 \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();