ticket: lighten ticket list output

This commit is contained in:
Keisuke Hirata 2026-06-09 19:38:09 +09:00
parent d95b3ffff6
commit 7368416e54
No known key found for this signature in database
4 changed files with 378 additions and 33 deletions

View File

@ -172,9 +172,11 @@ impl FeatureModule for TicketFeature {
fn tool_description(name: &str) -> &'static str { fn tool_description(name: &str) -> &'static str {
match name { match name {
"TicketCreate" => "Create a Ticket through the typed local Ticket backend.", "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" => { "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" => { "TicketComment" => {
"Append a comment/plan/decision/implementation_report event to a Ticket." "Append a comment/plan/decision/implementation_report event to a Ticket."

View File

@ -19,8 +19,10 @@ use crate::{
TicketStateChange, TicketSummary, TicketWorkflowState, TicketStateChange, TicketSummary, TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 100; const DEFAULT_LIST_LIMIT: usize = 50;
const MAX_LIST_LIMIT: usize = 200; 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 DEFAULT_EVENT_LIMIT: usize = 20;
const MAX_EVENT_LIMIT: usize = 100; const MAX_EVENT_LIMIT: usize = 100;
const DEFAULT_ARTIFACT_LIMIT: usize = 50; 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. \ 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 \ 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. Filter by \ const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
state (`planning`, `ready`, `queued`, `inprogress`, `done`, `closed`, or `all`). Output is a \ lightweight bounded overview for selection only. Filter by state (`planning`, `ready`, `queued`, \
bounded JSON summary list, not full ticket bodies."; `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 \ 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 \
artifact metadata."; artifact metadata.";
@ -212,7 +215,7 @@ struct TicketListParams {
/// State filter. Defaults to all Tickets. /// State filter. Defaults to all Tickets.
#[serde(default)] #[serde(default)]
state: Option<TicketListStateParam>, state: Option<TicketListStateParam>,
/// Maximum number of summaries to return. Defaults to 100, max 200. /// Maximum number of summaries to return. Defaults to 50, max 100.
#[serde(default)] #[serde(default)]
limit: Option<usize>, limit: Option<usize>,
} }
@ -482,7 +485,18 @@ struct TicketListOutput {
count: usize, count: usize,
returned: usize, returned: usize,
truncated: bool, truncated: bool,
tickets: Vec<Value>, limit: usize,
tickets: Vec<TicketListTicketOutput>,
}
#[derive(Debug, Serialize)]
struct TicketListTicketOutput {
id: String,
title: String,
state: String,
updated_at: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
hints: Vec<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -614,6 +628,7 @@ impl Tool for TicketListTool {
count, count,
returned: returned_tickets.len(), returned: returned_tickets.len(),
truncated: count > returned_tickets.len(), truncated: count > returned_tickets.len(),
limit,
tickets: returned_tickets, tickets: returned_tickets,
}; };
Ok(json_output( Ok(json_output(
@ -971,18 +986,38 @@ fn id_or_query(id: Option<String>, query: Option<String>) -> Result<TicketIdOrSl
} }
} }
fn ticket_summary_json(ticket: TicketSummary) -> Value { fn ticket_summary_json(ticket: TicketSummary) -> TicketListTicketOutput {
json!({ let hints = ticket_list_hints(&ticket);
"id": ticket.id, TicketListTicketOutput {
"title": ticket.title, id: ticket.id,
"state": ticket.workflow_state.as_str(), title: truncate_inline(ticket.title.as_str(), LIST_TITLE_MAX_CHARS),
"readiness": ticket.readiness, state: ticket.workflow_state.as_str().to_string(),
"action_required": ticket.action_required, updated_at: ticket.updated_at,
"attention_required": ticket.attention_required, hints,
"queued_by": ticket.queued_by, }
"queued_at": ticket.queued_at, }
"updated_at": ticket.updated_at,
}) fn ticket_list_hints(ticket: &TicketSummary) -> Vec<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
} }
fn ticket_relation_json(relation: &crate::TicketRelation) -> Value { 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::<Vec<_>>().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::<String>();
out.push_str(marker);
out
}
fn truncate_text(text: &str, max_bytes: usize) -> String { fn truncate_text(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes { if text.len() <= max_bytes {
return text.to_string(); return text.to_string();
@ -1411,6 +1458,188 @@ mod tests {
assert!(report.summary.contains("0 error(s)")); 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] #[tokio::test]
async fn ticket_relation_tools_record_query_and_show_derived_view() { async fn ticket_relation_tools_record_query_and_show_derived_view() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -10,9 +10,14 @@ 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, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TicketCli { pub enum TicketCli {
Help, Help,
@ -52,6 +57,7 @@ pub enum ListState {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListOptions { pub struct ListOptions {
pub state: ListState, pub state: ListState,
pub limit: Option<usize>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -312,14 +318,28 @@ fn list(
ListState::All => TicketFilter::all(), ListState::All => TicketFilter::all(),
}; };
let tickets = backend.list(filter)?; let tickets = backend.list(filter)?;
let mut stdout = String::from("state\tid\ttitle\tupdated_at\n"); let count = tickets.len();
for ticket in tickets { 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!( stdout.push_str(&format!(
"{}\t{}\t{}\t{}\n", "{}\t{}\t{}\t{}\t{}\n",
ticket.workflow_state.as_str(), ticket.workflow_state.as_str(),
ticket.id, ticket.id.as_str(),
ticket.title, title,
ticket.updated_at.unwrap_or_default() 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 <id> for details\n"
)); ));
} }
Ok(success(stdout)) Ok(success(stdout))
@ -656,10 +676,12 @@ 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::All;
let mut limit = None;
let mut i = 0; let mut i = 0;
while i < args.len() { while i < args.len() {
match option_with_value(args, &mut i)? { match option_with_value(args, &mut i)? {
Some(("--state", value)) => state = parse_list_state(&value)?, Some(("--state", value)) => state = parse_list_state(&value)?,
Some(("--limit", value)) => limit = Some(parse_list_limit(&value)?),
Some((name, _)) => { Some((name, _)) => {
return Err(TicketCliError::new(format!( return Err(TicketCliError::new(format!(
"unknown list argument: {name}" "unknown list argument: {name}"
@ -673,7 +695,7 @@ fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
} }
} }
} }
Ok(ListOptions { state }) Ok(ListOptions { state, limit })
} }
fn parse_relation(args: &[String]) -> Result<RelationOptions, TicketCliError> { fn parse_relation(args: &[String]) -> Result<RelationOptions, TicketCliError> {
@ -922,6 +944,7 @@ fn option_with_value(
"--kind", "--kind",
"--target", "--target",
"--note", "--note",
"--limit",
] { ] {
if arg == name { if arg == name {
let value = args let value = args
@ -957,6 +980,47 @@ fn parse_list_state(value: &str) -> Result<ListState, TicketCliError> {
} }
} }
fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
value
.parse::<usize>()
.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::<Vec<_>>().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::<String>();
out.push_str(marker);
out
}
fn parse_state_target(value: &str) -> Result<StateTarget, TicketCliError> { fn parse_state_target(value: &str) -> Result<StateTarget, TicketCliError> {
match value { match value {
"planning" => Ok(StateTarget::Planning), "planning" => Ok(StateTarget::Planning),
@ -1021,7 +1085,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]\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)] #[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] #[test]
fn ticket_cli_create_list_show_comment_review_state_close_and_doctor() { fn ticket_cli_create_list_show_comment_review_state_close_and_doctor() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -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: Pods with the Ticket built-in feature can use typed Ticket tools:
- `TicketCreate` - `TicketCreate`
- `TicketList` - `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
- `TicketShow` - `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
- `TicketComment` - `TicketComment`
- `TicketReview` - `TicketReview`
- `TicketWorkflowState` - `TicketWorkflowState`
@ -387,7 +387,7 @@ The product CLI exposes the typed Ticket backend for repository maintenance and
```sh ```sh
yoi ticket create --title "..." [--priority P2] 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 show <id>
yoi ticket comment <id> [--role comment|plan|decision|implementation_report] [--file path] yoi ticket comment <id> [--role comment|plan|decision|implementation_report] [--file path]
yoi ticket review <id> --approve|--request-changes [--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 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. `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: The current LocalTicketBackend stores records under: