ticket: update list query semantics

This commit is contained in:
Keisuke Hirata 2026-07-20 15:45:53 +09:00
parent edc495ac01
commit 0a5270a2cc
No known key found for this signature in database
6 changed files with 324 additions and 100 deletions

View File

@ -510,18 +510,75 @@ impl NewTicket {
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketFilter {
pub state: Option<TicketWorkflowState>,
#[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<TicketWorkflowState>),
}
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,
}
}
impl TicketFilter {
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<Item = TicketWorkflowState>) -> 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::<Vec<_>>()
.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<Vec<TicketSummary>>;
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
fn create(&self, input: NewTicket) -> Result<TicketRef>;
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<TicketPartialList> {
pub fn list_partial(&self, filter: TicketListQuery) -> Result<TicketPartialList> {
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<Vec<PathBuf>> {
fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result<Vec<PathBuf>> {
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<Vec<TicketRelation>> {
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<String>,
) -> Result<Vec<TicketRelation>> {
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<HashMap<String, TicketWorkflowState>> {
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<String>,
) -> Result<HashMap<String, TicketWorkflowState>> {
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<Vec<TicketSummary>> {
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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);

View File

@ -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<Vec<TicketSummary>> {
fn list(&self, filter: crate::TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
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<TicketWorkflowState> {
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<TicketListStateParam>,
/// Explicit workflow-state filter list. Cannot be combined with `state`.
#[serde(default)]
states: Option<Vec<TicketWorkflowStateParam>>,
/// Maximum number of summaries to return. Defaults to 50, max 100.
#[serde(default)]
limit: Option<usize>,
}
impl TicketListParams {
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), 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<ToolOutput, ToolError> {
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::<Vec<_>>();
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]

View File

@ -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<String, TicketStateOverlay>,
) -> ticket::Result<TicketRowsBuild> {
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")

View File

@ -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<Vec<TicketSummary>> {
fn list(&self, filter: TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::List { filter }),
TicketBackendOperationResult::Tickets

View File

@ -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<ProjectRecordList<TicketSummary>> {
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()

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();