ticket: lighten ticket list output
This commit is contained in:
parent
d95b3ffff6
commit
7368416e54
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user