From 7368416e54609517c2252beb6ae98f73fb7935ee Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 9 Jun 2026 19:38:09 +0900 Subject: [PATCH] ticket: lighten ticket list output --- crates/pod/src/feature/builtin/ticket.rs | 6 +- crates/ticket/src/tool.rs | 267 +++++++++++++++++++++-- crates/yoi/src/ticket_cli.rs | 130 ++++++++++- docs/development/work-items.md | 8 +- 4 files changed, 378 insertions(+), 33 deletions(-) diff --git a/crates/pod/src/feature/builtin/ticket.rs b/crates/pod/src/feature/builtin/ticket.rs index 7e231650..ac385b34 100644 --- a/crates/pod/src/feature/builtin/ticket.rs +++ b/crates/pod/src/feature/builtin/ticket.rs @@ -172,9 +172,11 @@ impl FeatureModule for TicketFeature { fn tool_description(name: &str) -> &'static str { match name { "TicketCreate" => "Create a Ticket through the typed local Ticket backend.", - "TicketList" => "List Tickets through the typed local Ticket backend with bounded output.", + "TicketList" => { + "List Tickets as a lightweight bounded overview for id selection; use TicketShow before decisions." + } "TicketShow" => { - "Show one Ticket through the typed local Ticket backend with bounded output." + "Show one Ticket through the typed local Ticket backend as the detailed authority." } "TicketComment" => { "Append a comment/plan/decision/implementation_report event to a Ticket." diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 363cfa31..87a8b7a3 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -19,8 +19,10 @@ use crate::{ TicketStateChange, TicketSummary, TicketWorkflowState, }; -const DEFAULT_LIST_LIMIT: usize = 100; -const MAX_LIST_LIMIT: usize = 200; +const DEFAULT_LIST_LIMIT: usize = 50; +const MAX_LIST_LIMIT: usize = 100; +const LIST_TITLE_MAX_CHARS: usize = 96; +const LIST_HINT_MAX_CHARS: usize = 80; const DEFAULT_EVENT_LIMIT: usize = 20; const MAX_EVENT_LIMIT: usize = 100; const DEFAULT_ARTIFACT_LIMIT: usize = 50; @@ -68,9 +70,10 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [ const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \ 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. Filter by \ -state (`planning`, `ready`, `queued`, `inprogress`, `done`, `closed`, or `all`). Output is a \ -bounded JSON summary list, not full ticket bodies."; +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 \ +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 \ artifact metadata."; @@ -212,7 +215,7 @@ struct TicketListParams { /// State filter. Defaults to all Tickets. #[serde(default)] state: Option, - /// Maximum number of summaries to return. Defaults to 100, max 200. + /// Maximum number of summaries to return. Defaults to 50, max 100. #[serde(default)] limit: Option, } @@ -482,7 +485,18 @@ struct TicketListOutput { count: usize, returned: usize, truncated: bool, - tickets: Vec, + limit: usize, + tickets: Vec, +} + +#[derive(Debug, Serialize)] +struct TicketListTicketOutput { + id: String, + title: String, + state: String, + updated_at: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + hints: Vec, } #[derive(Debug, Serialize)] @@ -614,6 +628,7 @@ impl Tool for TicketListTool { count, returned: returned_tickets.len(), truncated: count > returned_tickets.len(), + limit, tickets: returned_tickets, }; Ok(json_output( @@ -971,18 +986,38 @@ fn id_or_query(id: Option, query: Option) -> Result Value { - json!({ - "id": ticket.id, - "title": ticket.title, - "state": ticket.workflow_state.as_str(), - "readiness": ticket.readiness, - "action_required": ticket.action_required, - "attention_required": ticket.attention_required, - "queued_by": ticket.queued_by, - "queued_at": ticket.queued_at, - "updated_at": ticket.updated_at, - }) +fn ticket_summary_json(ticket: TicketSummary) -> TicketListTicketOutput { + let hints = ticket_list_hints(&ticket); + TicketListTicketOutput { + id: ticket.id, + title: truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS), + state: ticket.workflow_state.as_str().to_string(), + updated_at: ticket.updated_at, + hints, + } +} + +fn ticket_list_hints(ticket: &TicketSummary) -> Vec { + let mut hints = Vec::new(); + if let Some(attention) = ticket.attention_required.as_deref() { + hints.push(format!( + "attention:{}", + truncate_inline(attention, LIST_HINT_MAX_CHARS) + )); + } + if let Some(action) = ticket.action_required.as_deref() { + hints.push(format!( + "action:{}", + truncate_inline(action, LIST_HINT_MAX_CHARS) + )); + } + if let Some(readiness) = ticket.readiness.as_deref() { + hints.push(format!( + "readiness:{}", + truncate_inline(readiness, LIST_HINT_MAX_CHARS) + )); + } + hints } fn ticket_relation_json(relation: &crate::TicketRelation) -> Value { @@ -1150,6 +1185,18 @@ fn diagnostic_json(diagnostic: TicketDoctorDiagnostic) -> Value { }) } +fn truncate_inline(text: &str, max_chars: usize) -> String { + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= max_chars { + return normalized; + } + let marker = "..."; + let take = max_chars.saturating_sub(marker.chars().count()); + let mut out = normalized.chars().take(take).collect::(); + out.push_str(marker); + out +} + fn truncate_text(text: &str, max_bytes: usize) -> String { if text.len() <= max_bytes { return text.to_string(); @@ -1411,6 +1458,188 @@ mod tests { assert!(report.summary.contains("0 error(s)")); } + #[tokio::test] + async fn ticket_list_tool_truncates_long_titles_and_hints() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let list = tool_by_name(backend.clone(), "TicketList"); + let mut ticket = NewTicket::new(format!( + "Long Title {}", + "x".repeat(LIST_TITLE_MAX_CHARS + 40) + )); + ticket.attention_required = Some(format!( + "Needs attention {}", + "a".repeat(LIST_HINT_MAX_CHARS + 40) + )); + backend.create(ticket).unwrap(); + + let listed = list.execute(&json!({}).to_string()).await.unwrap(); + let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap(); + let title = listed_json["tickets"][0]["title"].as_str().unwrap(); + assert!(title.chars().count() <= LIST_TITLE_MAX_CHARS); + assert!(title.ends_with("...")); + let hint = listed_json["tickets"][0]["hints"][0].as_str().unwrap(); + assert!(hint.chars().count() <= "attention:".chars().count() + LIST_HINT_MAX_CHARS); + assert!(hint.ends_with("...")); + } + + #[tokio::test] + async fn ticket_list_tool_default_and_max_limits_are_bounded() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let list = tool_by_name(backend.clone(), "TicketList"); + for index in 0..(MAX_LIST_LIMIT + 5) { + backend + .create(NewTicket::new(format!("Ticket {index:03}"))) + .unwrap(); + } + + let default_list = list.execute(&json!({}).to_string()).await.unwrap(); + let default_json: Value = serde_json::from_str(&default_list.content.unwrap()).unwrap(); + assert_eq!( + default_json["count"].as_u64(), + Some((MAX_LIST_LIMIT + 5) as u64) + ); + assert_eq!( + default_json["returned"].as_u64(), + Some(DEFAULT_LIST_LIMIT as u64) + ); + assert_eq!( + default_json["limit"].as_u64(), + Some(DEFAULT_LIST_LIMIT as u64) + ); + assert_eq!(default_json["truncated"].as_bool(), Some(true)); + assert_eq!( + default_json["tickets"].as_array().unwrap().len(), + DEFAULT_LIST_LIMIT + ); + + let high_limit = list + .execute(&json!({ "limit": MAX_LIST_LIMIT + 500 }).to_string()) + .await + .unwrap(); + let high_json: Value = serde_json::from_str(&high_limit.content.unwrap()).unwrap(); + assert_eq!(high_json["returned"].as_u64(), Some(MAX_LIST_LIMIT as u64)); + assert_eq!(high_json["limit"].as_u64(), Some(MAX_LIST_LIMIT as u64)); + assert_eq!(high_json["truncated"].as_bool(), Some(true)); + assert_eq!( + high_json["tickets"].as_array().unwrap().len(), + MAX_LIST_LIMIT + ); + } + + #[tokio::test] + async fn ticket_list_tool_caps_all_and_closed_default_listing() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let list = tool_by_name(backend.clone(), "TicketList"); + for index in 0..(DEFAULT_LIST_LIMIT + 3) { + let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}")); + ticket.workflow_state = Some(TicketWorkflowState::Closed); + backend.create(ticket).unwrap(); + } + for index in 0..3 { + backend + .create(NewTicket::new(format!("Planning Ticket {index:03}"))) + .unwrap(); + } + + let all = list + .execute(&json!({ "state": "all" }).to_string()) + .await + .unwrap(); + let all_json: Value = serde_json::from_str(&all.content.unwrap()).unwrap(); + assert_eq!(all_json["state_filter"], "all"); + assert_eq!( + all_json["returned"].as_u64(), + Some(DEFAULT_LIST_LIMIT as u64) + ); + assert_eq!(all_json["truncated"].as_bool(), Some(true)); + + let closed = list + .execute(&json!({ "state": "closed" }).to_string()) + .await + .unwrap(); + let closed_json: Value = serde_json::from_str(&closed.content.unwrap()).unwrap(); + assert_eq!(closed_json["state_filter"], "closed"); + assert_eq!( + closed_json["count"].as_u64(), + Some((DEFAULT_LIST_LIMIT + 3) as u64) + ); + assert_eq!( + closed_json["returned"].as_u64(), + Some(DEFAULT_LIST_LIMIT as u64) + ); + assert_eq!(closed_json["truncated"].as_bool(), Some(true)); + } + + #[tokio::test] + async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let list = tool_by_name(backend.clone(), "TicketList"); + let close = tool_by_name(backend.clone(), "TicketClose"); + let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST"; + let thread_secret = "THREAD_SECRET_DO_NOT_LIST"; + let artifact_secret = "ARTIFACT_SECRET_DO_NOT_LIST"; + let resolution_secret = "RESOLUTION_SECRET_DO_NOT_LIST"; + let mut ticket = NewTicket::new("Leak Probe"); + ticket.body = MarkdownText::new(format!("Item body {body_secret}")); + ticket.workflow_state = Some(TicketWorkflowState::Done); + let created = backend.create(ticket).unwrap(); + backend + .add_event( + TicketIdOrSlug::Id(created.id.clone()), + NewTicketEvent::new(TicketEventKind::Comment, format!("Thread {thread_secret}")), + ) + .unwrap(); + std::fs::write( + temp.path() + .join("tickets") + .join(&created.id) + .join("artifacts") + .join("secret.txt"), + artifact_secret, + ) + .unwrap(); + close + .execute( + &json!({ + "ticket": created.id, + "resolution": format!("Resolution {resolution_secret}") + }) + .to_string(), + ) + .await + .unwrap(); + + let listed = list + .execute(&json!({ "state": "closed" }).to_string()) + .await + .unwrap(); + let listed_content = listed.content.unwrap(); + for secret in [ + body_secret, + thread_secret, + artifact_secret, + resolution_secret, + ] { + assert!(!listed_content.contains(secret)); + } + let listed_json: Value = serde_json::from_str(&listed_content).unwrap(); + let ticket = listed_json["tickets"][0].as_object().unwrap(); + for forbidden_key in [ + "body", + "document", + "events", + "thread", + "artifacts", + "resolution", + ] { + assert!(!ticket.contains_key(forbidden_key)); + } + } + #[tokio::test] async fn ticket_relation_tools_record_query_and_show_derived_view() { let temp = TempDir::new().unwrap(); diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 7722d3f4..9f65efb6 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -10,9 +10,14 @@ use ticket::config::{ use ticket::{ LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary, - TicketRelationKind, TicketReview, TicketReviewResult, TicketWorkflowState, + TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState, }; +const DEFAULT_LIST_LIMIT: usize = 50; +const MAX_LIST_LIMIT: usize = 100; +const LIST_TITLE_MAX_CHARS: usize = 96; +const LIST_HINT_MAX_CHARS: usize = 80; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum TicketCli { Help, @@ -52,6 +57,7 @@ pub enum ListState { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListOptions { pub state: ListState, + pub limit: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -312,14 +318,28 @@ fn list( ListState::All => TicketFilter::all(), }; let tickets = backend.list(filter)?; - let mut stdout = String::from("state\tid\ttitle\tupdated_at\n"); - for ticket in tickets { + let count = tickets.len(); + let limit = options + .limit + .unwrap_or(DEFAULT_LIST_LIMIT) + .min(MAX_LIST_LIMIT); + let mut stdout = String::from("state\tid\ttitle\tupdated_at\thints\n"); + for ticket in tickets.into_iter().take(limit) { + let title = truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS); + let updated_at = ticket.updated_at.as_deref().unwrap_or_default(); + let hints = ticket_cli_hints(&ticket); stdout.push_str(&format!( - "{}\t{}\t{}\t{}\n", + "{}\t{}\t{}\t{}\t{}\n", ticket.workflow_state.as_str(), - ticket.id, - ticket.title, - ticket.updated_at.unwrap_or_default() + ticket.id.as_str(), + title, + updated_at, + hints + )); + } + if count > limit { + stdout.push_str(&format!( + "# truncated: returned {limit} of {count}; use --limit up to {MAX_LIST_LIMIT} or a narrower --state, then yoi ticket show for details\n" )); } Ok(success(stdout)) @@ -656,10 +676,12 @@ fn parse_create(args: &[String]) -> Result { fn parse_list(args: &[String]) -> Result { let mut state = ListState::All; + let mut limit = None; let mut i = 0; while i < args.len() { match option_with_value(args, &mut i)? { Some(("--state", value)) => state = parse_list_state(&value)?, + Some(("--limit", value)) => limit = Some(parse_list_limit(&value)?), Some((name, _)) => { return Err(TicketCliError::new(format!( "unknown list argument: {name}" @@ -673,7 +695,7 @@ fn parse_list(args: &[String]) -> Result { } } } - Ok(ListOptions { state }) + Ok(ListOptions { state, limit }) } fn parse_relation(args: &[String]) -> Result { @@ -922,6 +944,7 @@ fn option_with_value( "--kind", "--target", "--note", + "--limit", ] { if arg == name { let value = args @@ -957,6 +980,47 @@ fn parse_list_state(value: &str) -> Result { } } +fn parse_list_limit(value: &str) -> Result { + value + .parse::() + .map_err(|_| TicketCliError::new(format!("invalid limit: {value}"))) +} + +fn ticket_cli_hints(ticket: &TicketSummary) -> String { + let mut hints = Vec::new(); + if let Some(attention) = ticket.attention_required.as_deref() { + hints.push(format!( + "attention:{}", + truncate_inline(attention, LIST_HINT_MAX_CHARS) + )); + } + if let Some(action) = ticket.action_required.as_deref() { + hints.push(format!( + "action:{}", + truncate_inline(action, LIST_HINT_MAX_CHARS) + )); + } + if let Some(readiness) = ticket.readiness.as_deref() { + hints.push(format!( + "readiness:{}", + truncate_inline(readiness, LIST_HINT_MAX_CHARS) + )); + } + hints.join("; ") +} + +fn truncate_inline(text: &str, max_chars: usize) -> String { + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= max_chars { + return normalized; + } + let marker = "..."; + let take = max_chars.saturating_sub(marker.chars().count()); + let mut out = normalized.chars().take(take).collect::(); + out.push_str(marker); + out +} + fn parse_state_target(value: &str) -> Result { match value { "planning" => Ok(StateTarget::Planning), @@ -1021,7 +1085,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]\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 .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, 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 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 .yoi/ticket.config.toml with explicit fixed role profiles and an optional commented [ticket].language setting.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n" } #[cfg(test)] @@ -1099,6 +1163,54 @@ mod tests { ); } + #[test] + fn ticket_cli_list_is_bounded_and_truncates_titles() { + let temp = TempDir::new().unwrap(); + let long_title = format!("Long {}", "x".repeat(LIST_TITLE_MAX_CHARS + 40)); + let first = run(&temp, &["create", "--title", long_title.as_str()]); + assert_eq!(first.status, TicketCliStatus::Success); + for index in 0..DEFAULT_LIST_LIMIT { + let title = format!("Ticket {index:03}"); + let created = run(&temp, &["create", "--title", title.as_str()]); + assert_eq!(created.status, TicketCliStatus::Success); + } + + let listed = run(&temp, &["list", "--state", "all"]); + assert_eq!(listed.status, TicketCliStatus::Success); + assert!(listed.stdout.contains("# truncated: returned")); + let non_note_lines = listed + .stdout + .lines() + .filter(|line| !line.starts_with('#')) + .count(); + assert_eq!(non_note_lines, DEFAULT_LIST_LIMIT + 1); + let first_ticket_line = listed.stdout.lines().nth(1).unwrap(); + let listed_title = first_ticket_line.split('\t').nth(2).unwrap(); + assert!(listed_title.starts_with("Long ")); + assert!(listed_title.chars().count() <= LIST_TITLE_MAX_CHARS); + assert!(listed_title.ends_with("...")); + } + + #[test] + fn ticket_cli_list_limit_is_capped() { + let temp = TempDir::new().unwrap(); + for index in 0..(MAX_LIST_LIMIT + 5) { + let title = format!("Ticket {index:03}"); + let created = run(&temp, &["create", "--title", title.as_str()]); + assert_eq!(created.status, TicketCliStatus::Success); + } + + let listed = run(&temp, &["list", "--state", "all", "--limit", "1000"]); + assert_eq!(listed.status, TicketCliStatus::Success); + assert!(listed.stdout.contains("# truncated: returned")); + let non_note_lines = listed + .stdout + .lines() + .filter(|line| !line.starts_with('#')) + .count(); + assert_eq!(non_note_lines, MAX_LIST_LIMIT + 1); + } + #[test] fn ticket_cli_create_list_show_comment_review_state_close_and_doctor() { let temp = TempDir::new().unwrap(); diff --git a/docs/development/work-items.md b/docs/development/work-items.md index 6d751257..ced58796 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -34,8 +34,8 @@ Maintainers can inspect the local `.yoi/tickets/` files directly when debugging Pods with the Ticket built-in feature can use typed Ticket tools: - `TicketCreate` -- `TicketList` -- `TicketShow` +- `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority. +- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds. - `TicketComment` - `TicketReview` - `TicketWorkflowState` @@ -387,7 +387,7 @@ The product CLI exposes the typed Ticket backend for repository maintenance and ```sh yoi ticket create --title "..." [--priority P2] -yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] +yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit n] yoi ticket show <id> yoi ticket comment <id> [--role comment|plan|decision|implementation_report] [--file path] yoi ticket review <id> --approve|--request-changes [--file path] @@ -396,6 +396,8 @@ yoi ticket close <id> [--resolution text|--file path] yoi ticket doctor ``` +`yoi ticket list` is a capped overview/selection command. It should remain readable for humans and safe for model context: use it to find a canonical id, then use `yoi ticket show <id>` before routing, closing, planning, or implementation decisions. + `yoi ticket state` records current lifecycle transitions among active states. Closing must use `yoi ticket close` so the backend writes the required `resolution.md` and passes `yoi ticket doctor`; `done` and `closed` remain distinct states. The current LocalTicketBackend stores records under: