ticket: lighten ticket list output

This commit is contained in:
2026-06-09 19:38:09 +09:00
parent d95b3ffff6
commit 7368416e54
4 changed files with 378 additions and 33 deletions
+248 -19
View File
@@ -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)]
@@ -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<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 {
@@ -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 {
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();