merge: lighten ticket list output
This commit is contained in:
commit
b6ac36e65e
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
|
@ -482,7 +485,18 @@ struct TicketListOutput {
|
|||
count: usize,
|
||||
returned: usize,
|
||||
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)]
|
||||
|
|
@ -622,6 +636,7 @@ impl Tool for TicketListTool {
|
|||
count,
|
||||
returned: returned_tickets.len(),
|
||||
truncated: count > returned_tickets.len(),
|
||||
limit,
|
||||
tickets: returned_tickets,
|
||||
};
|
||||
Ok(json_output(
|
||||
|
|
@ -1023,18 +1038,38 @@ fn id_or_query(id: Option<String>, query: Option<String>) -> Result<TicketIdOrSl
|
|||
}
|
||||
}
|
||||
|
||||
fn ticket_summary_json(ticket: TicketSummary) -> 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<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 {
|
||||
|
|
@ -1202,6 +1237,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 {
|
||||
if text.len() <= max_bytes {
|
||||
return text.to_string();
|
||||
|
|
@ -1473,6 +1520,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();
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
}
|
||||
|
||||
#[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 <id> for details\n"
|
||||
));
|
||||
}
|
||||
Ok(success(stdout))
|
||||
|
|
@ -656,10 +676,12 @@ fn parse_create(args: &[String]) -> Result<CreateOptions, TicketCliError> {
|
|||
|
||||
fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
|
||||
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<ListOptions, TicketCliError> {
|
|||
}
|
||||
}
|
||||
}
|
||||
Ok(ListOptions { state })
|
||||
Ok(ListOptions { state, limit })
|
||||
}
|
||||
|
||||
fn parse_relation(args: &[String]) -> Result<RelationOptions, TicketCliError> {
|
||||
|
|
@ -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<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> {
|
||||
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 <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();
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user