ticket: merge TicketListQuery implementation

This commit is contained in:
Keisuke Hirata 2026-07-20 17:09:17 +09:00
commit 39be7a5a0a
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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketFilter { #[serde(rename_all = "snake_case")]
pub state: Option<TicketWorkflowState>, 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 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 { pub fn all() -> Self {
Self { state: None } Self {
state: TicketStateSelector::All,
}
} }
pub fn state(state: TicketWorkflowState) -> Self { 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 { pub trait TicketBackend {
fn default_intake_ready_state_change_body(&self, from: &str) -> String; 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 show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
fn create(&self, input: NewTicket) -> Result<TicketRef>; fn create(&self, input: NewTicket) -> Result<TicketRef>;
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
@ -926,7 +983,7 @@ pub enum TicketBackendOperation {
from: String, from: String,
}, },
List { List {
filter: TicketFilter, filter: TicketListQuery,
}, },
Show { Show {
id: TicketIdOrSlug, 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 output = TicketPartialList::default();
let mut invalid_seen = BTreeSet::new(); 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"); let item = dir.join("item.md");
if !item.exists() { if !item.exists() {
continue; continue;
@ -1155,10 +1212,7 @@ impl LocalTicketBackend {
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
{ {
Ok(meta) => { Ok(meta) => {
if filter if !filter.matches_state(meta.workflow_state) {
.state
.is_some_and(|state| meta.workflow_state != state)
{
continue; continue;
} }
output.tickets.push(ticket_summary_from_meta(meta)); 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(); let mut dirs = Vec::new();
if !self.root.exists() { if !self.root.exists() {
return Ok(dirs); return Ok(dirs);
@ -1275,10 +1329,10 @@ impl LocalTicketBackend {
if !item.is_file() { if !item.is_file() {
continue; continue;
} }
if let Some(state) = filter.state { if !matches!(filter.state, TicketStateSelector::All) {
let parsed = read_item_file(&item)?; let parsed = read_item_file(&item)?;
let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?; let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?;
if meta.workflow_state != state { if !filter.matches_state(meta.workflow_state) {
continue; continue;
} }
} }
@ -1495,7 +1549,7 @@ impl LocalTicketBackend {
fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> { fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> {
let mut relations = Vec::new(); 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)?); relations.extend(self.read_ticket_relations_for_dir(&dir)?);
} }
sort_ticket_relations(&mut relations); sort_ticket_relations(&mut relations);
@ -1508,7 +1562,7 @@ impl LocalTicketBackend {
invalid_seen: &mut BTreeSet<String>, invalid_seen: &mut BTreeSet<String>,
) -> Result<Vec<TicketRelation>> { ) -> Result<Vec<TicketRelation>> {
let mut relations = Vec::new(); 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) { match self.read_ticket_relations_for_dir(&dir) {
Ok(records) => relations.extend(records), Ok(records) => relations.extend(records),
Err(error) => { Err(error) => {
@ -1539,7 +1593,7 @@ impl LocalTicketBackend {
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> { fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
let mut states = HashMap::new(); 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 item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
states.insert(meta.id, meta.workflow_state); states.insert(meta.id, meta.workflow_state);
@ -1553,7 +1607,7 @@ impl LocalTicketBackend {
invalid_seen: &mut BTreeSet<String>, invalid_seen: &mut BTreeSet<String>,
) -> Result<HashMap<String, TicketWorkflowState>> { ) -> Result<HashMap<String, TicketWorkflowState>> {
let mut states = HashMap::new(); 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 item = dir.join("item.md");
match read_item_file(&item) match read_item_file(&item)
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) .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) 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(); let mut tickets = Vec::new();
for dir in self.iter_ticket_dirs(filter)? { for dir in self.iter_ticket_dirs(filter)? {
let item = dir.join("item.md"); let item = dir.join("item.md");
@ -2088,7 +2142,7 @@ impl TicketBackend for LocalTicketBackend {
let dir = self.find_ticket_dir(&ticket)?; let dir = self.find_ticket_dir(&ticket)?;
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
} else { } 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)?); 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) { let ticket_id = match ticket_id_from_dir(&dir) {
Ok(id) => id, Ok(id) => id,
Err(err) => { Err(err) => {
@ -3975,6 +4029,57 @@ state: planning
assert!(err.contains("invalid YAML frontmatter"), "{err}"); 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] #[test]
fn create_writes_local_ticket_layout() { fn create_writes_local_ticket_layout() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@ -4036,9 +4141,9 @@ state: planning
) )
.unwrap(); .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.len(), 1);
assert_eq!(partial.tickets[0].id, valid.id); assert_eq!(partial.tickets[0].id, valid.id);
assert_eq!(partial.invalid_records.len(), 1); 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 \ 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."; 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 \ 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`, \ lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \ state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
routing, closing, planning, or implementation decisions."; routing, closing, planning, or implementation decisions.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \ 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 \ 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) 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) 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")] #[serde(rename_all = "snake_case")]
enum TicketListStateParam { enum TicketListStateParam {
Active,
Planning, Planning,
Ready, Ready,
Queued, Queued,
@ -388,47 +389,62 @@ enum TicketListStateParam {
} }
impl TicketListStateParam { impl TicketListStateParam {
fn as_filter(self) -> (crate::TicketFilter, &'static str) { fn as_state(self) -> Option<TicketWorkflowState> {
match self { match self {
Self::Planning => ( Self::Planning => Some(TicketWorkflowState::Planning),
crate::TicketFilter::state(TicketWorkflowState::Planning), Self::Ready => Some(TicketWorkflowState::Ready),
"planning", Self::Queued => Some(TicketWorkflowState::Queued),
), Self::Inprogress => Some(TicketWorkflowState::InProgress),
Self::Ready => ( Self::Done => Some(TicketWorkflowState::Done),
crate::TicketFilter::state(TicketWorkflowState::Ready), Self::Closed => Some(TicketWorkflowState::Closed),
"ready", Self::Active | Self::All => None,
),
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"),
} }
} }
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketListParams { 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)] #[serde(default)]
state: Option<TicketListStateParam>, 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. /// Maximum number of summaries to return. Defaults to 50, max 100.
#[serde(default)] #[serde(default)]
limit: Option<usize>, 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)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketShowParams { struct TicketShowParams {
/// Ticket id. Exactly one of `id` or `query` must be provided. /// Ticket id. Exactly one of `id` or `query` must be provided.
@ -825,9 +841,10 @@ impl Tool for TicketListTool {
_ctx: llm_engine::tool::ToolExecutionContext, _ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let params: TicketListParams = parse_input("TicketList", input_json)?; let params: TicketListParams = parse_input("TicketList", input_json)?;
let state = params.state.unwrap_or(TicketListStateParam::All); let (filter, state_filter, params_limit) = params
let (filter, state_filter) = state.as_filter(); .into_query()
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); .map_err(|error| backend_error("TicketList", error))?;
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let tickets = self let tickets = self
.backend .backend
.list(filter) .list(filter)
@ -1825,6 +1842,16 @@ mod tests {
.unwrap(); .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 let all = list
.execute(&json!({ "state": "all" }).to_string(), Default::default()) .execute(&json!({ "state": "all" }).to_string(), Default::default())
.await .await
@ -1857,6 +1884,53 @@ mod tests {
assert_eq!(closed_json["truncated"].as_bool(), Some(true)); 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] #[tokio::test]
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -2408,7 +2482,10 @@ mod tests {
assert!(!id.contains("escape")); assert!(!id.contains("escape"));
assert!(!temp.path().join("escape").exists()); assert!(!temp.path().join("escape").exists());
assert!(temp.path().join("tickets").join(id).is_dir()); 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] #[test]

View File

@ -10,8 +10,9 @@ use ticket::config::{
WORKSPACE_SETTINGS_RELATIVE_PATH, WORKSPACE_SETTINGS_RELATIVE_PATH,
}; };
use ticket::{ use ticket::{
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug,
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState, TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary,
TicketWorkflowState,
}; };
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore}; 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()) let backend = LocalTicketBackend::new(ticket_root.to_path_buf())
.with_record_language(overlay_config.ticket_record_language()); .with_record_language(overlay_config.ticket_record_language());
let partial = backend let partial = backend
.list_partial(TicketFilter::all()) .list_partial(TicketListQuery::all())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let mut states = BTreeMap::new(); let mut states = BTreeMap::new();
for summary in partial.tickets { for summary in partial.tickets {
@ -1092,7 +1093,7 @@ fn build_ticket_rows(
registry: &PanelRegistrySnapshot, registry: &PanelRegistrySnapshot,
orchestration_overlay: &BTreeMap<String, TicketStateOverlay>, orchestration_overlay: &BTreeMap<String, TicketStateOverlay>,
) -> ticket::Result<TicketRowsBuild> { ) -> 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 ticket_rows = Vec::new();
let mut invalid_records = partial.invalid_records; let mut invalid_records = partial.invalid_records;
for summary in partial.tickets { for summary in partial.tickets {
@ -2522,7 +2523,7 @@ mod tests {
input.workflow_state = Some(TicketWorkflowState::Ready); input.workflow_state = Some(TicketWorkflowState::Ready);
}); });
let ticket_id = backend let ticket_id = backend
.list(TicketFilter::all()) .list(TicketListQuery::all())
.unwrap() .unwrap()
.into_iter() .into_iter()
.find(|ticket| ticket.title == "Ticket With Intake") .find(|ticket| ticket.title == "Ticket With Intake")
@ -2619,7 +2620,7 @@ mod tests {
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Claimed Planning", |_| {}); 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")); let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
store store
.claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake") .claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake")

View File

@ -10,9 +10,9 @@ use ticket::{
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation, Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketFilter, TicketIdOrSlug, TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
TicketIntakeSummary, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
TicketReview, TicketStateChange, TicketSummary, TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::{ tool::{
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, 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!( expect_ticket_result!(
self.invoke(TicketBackendOperation::List { filter }), self.invoke(TicketBackendOperation::List { filter }),
TicketBackendOperationResult::Tickets TicketBackendOperationResult::Tickets

View File

@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use project_record::validate_record_id; use project_record::validate_record_id;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ticket::config::TicketConfig; use ticket::config::TicketConfig;
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery};
use crate::{Error, Result}; use crate::{Error, Result};
@ -35,7 +35,7 @@ impl LocalProjectRecordReader {
} }
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> { 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 let mut items = partial
.tickets .tickets
.into_iter() .into_iter()

View File

@ -11,7 +11,7 @@ use ticket::config::{
}; };
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState,
}; };
@ -45,15 +45,11 @@ pub struct CreateOptions {
pub title: String, pub title: String,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListState { pub enum ListState {
Planning, Active,
Ready,
Queued,
InProgress,
Done,
Closed,
All, All,
States(Vec<TicketWorkflowState>),
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -381,13 +377,9 @@ fn list(
options: ListOptions, options: ListOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let filter = match options.state { let filter = match options.state {
ListState::Planning => TicketFilter::state(TicketWorkflowState::Planning), ListState::Active => TicketListQuery::active(),
ListState::Ready => TicketFilter::state(TicketWorkflowState::Ready), ListState::All => TicketListQuery::all(),
ListState::Queued => TicketFilter::state(TicketWorkflowState::Queued), ListState::States(states) => TicketListQuery::states(states),
ListState::InProgress => TicketFilter::state(TicketWorkflowState::InProgress),
ListState::Done => TicketFilter::state(TicketWorkflowState::Done),
ListState::Closed => TicketFilter::state(TicketWorkflowState::Closed),
ListState::All => TicketFilter::all(),
}; };
let tickets = backend.list(filter)?; let tickets = backend.list(filter)?;
let count = tickets.len(); let count = tickets.len();
@ -750,7 +742,7 @@ fn parse_create(args: &[String]) -> Result<CreateOptions, TicketCliError> {
} }
fn parse_list(args: &[String]) -> Result<ListOptions, 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 limit = None;
let mut i = 0; let mut i = 0;
while i < args.len() { while i < args.len() {
@ -1042,17 +1034,42 @@ fn option_with_value(
Ok(None) Ok(None)
} }
fn parse_list_state(value: &str) -> Result<ListState, TicketCliError> { fn parse_list_state(raw: &str) -> Result<ListState, TicketCliError> {
match value { let tokens = raw
"planning" => Ok(ListState::Planning), .split(',')
"ready" => Ok(ListState::Ready), .map(str::trim)
"queued" => Ok(ListState::Queued), .filter(|token| !token.is_empty())
"inprogress" => Ok(ListState::InProgress), .collect::<Vec<_>>();
"done" => Ok(ListState::Done), if tokens.is_empty() {
"closed" => Ok(ListState::Closed), return Err(TicketCliError::new("--state must not be empty"));
"all" => Ok(ListState::All),
_ => Err(TicketCliError::new(format!("invalid state: {value}"))),
} }
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> { fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
@ -1148,7 +1165,7 @@ fn default_author() -> String {
} }
fn help_text() -> &'static str { 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)] #[cfg(test)]
@ -1533,6 +1550,30 @@ mod tests {
assert!(err.to_string().contains("use `yoi ticket close")); 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] #[test]
fn ticket_cli_help_lists_required_commands() { fn ticket_cli_help_lists_required_commands() {
let help = parse_ticket_args(&args(&["--help"])).unwrap(); let help = parse_ticket_args(&args(&["--help"])).unwrap();