//! Ticket management tools for the Manager role.
//!
//! These tools allow the Manager to create tickets on the board,
//! update their status, list them, and inspect individual tickets.
use crate::Role;
use crate::Workspace;
use crate::board::TicketPhase;
use crate::tools::Tool;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::fmt::Write;
// ── CreateTicketTool ─────────────────────────────────────────────
/// Tool for creating tickets. The `reporter` field is set at construction
/// time based on which role is using the tool — invisible to the agent,
/// no parameter to pass.
pub struct CreateTicketTool {
reporter: String,
}
impl CreateTicketTool {
pub fn new(reporter: impl Into<String>) -> Self {
Self {
reporter: reporter.into(),
}
}
}
#[async_trait]
impl Tool for CreateTicketTool {
fn name(&self) -> &'static str {
"create_ticket"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"title": {
"type": "string",
"description": "Short title/summary of the task"
},
"description": {
"type": "string",
"description": "Detailed description of the task"
},
"prerequisites": {
"type": "array",
"description": "Optional list of ticket IDs that must be completed (done, archived, or cancelled) before this ticket can be claimed",
"items": {
"type": "string"
}
},
"supersede": {
"type": "string",
"description": "Optional ticket ID to supersede — atomically cancels the old ticket, creates this one as its replacement, and rewires any dependents to point to the new ID"
},
}),
&["title", "description"],
)
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> Result<String> {
let title = super::get_str(&args, "title")?;
let description = super::get_str(&args, "description")?;
let prerequisites: Vec<String> = match args.get("prerequisites") {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
Some(_) => {
anyhow::bail!("prerequisites must be an array of ticket ID strings");
}
None => Vec::new(),
};
let supersede_id: Option<String> = match args.get("supersede") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(_) => anyhow::bail!("supersede must be a ticket ID string"),
None => None,
};
let store = crate::board::store();
let embedding_bytes: Option<Vec<u8>> =
crate::embedder::embed(title, false).map(|v| crate::vector::vec_to_bytes(&v));
let prereq_note = if prerequisites.is_empty() {
String::new()
} else {
format!(" with prerequisites: {}", prerequisites.join(", "))
};
if let Some(supersede_id) = supersede_id {
guard_not_pipeline_blocking(store, &supersede_id).await?;
let id = store
.supersede_and_create(
&supersede_id,
title,
description,
ws,
&prerequisites,
&self.reporter,
embedding_bytes.as_deref(),
)
.await?;
Ok(format!(
"Superseded {supersede_id} → created ticket {id}: {title}{prereq_note}"
))
} else {
let id = store
.create_ticket(
title,
description,
ws,
TicketPhase::Backlog,
&prerequisites,
&self.reporter,
embedding_bytes.as_deref(),
)
.await?;
Ok(format!("Created ticket {id}: {title}{prereq_note}"))
}
}
}
// ── UpdateTicketTool ─────────────────────────────────────────────
pub struct UpdateTicketTool;
#[async_trait]
impl Tool for UpdateTicketTool {
fn name(&self) -> &'static str {
"update_ticket"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"ticket_id": {
"type": "string",
"description": "The ticket id"
},
"status": {
"type": "string",
"description": "New status for the ticket. Valid manual transitions: backlog (return to queue), ready_for_development (send to engineer), paused (temporarily hold), cancelled (abandon), failed (mark unsuccessful), done (mark complete). Do NOT manually set pipeline-managed phases (analysis, planning, in_development, in_diagnostics, diagnostics_done, in_review, reviewed, in_qa, qa_passed) — the board poller handles these automatically and manual transitions will interfere with running agents."
}
}),
&["ticket_id", "status"],
)
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> Result<String> {
let ticket_id = super::get_str(&args, "ticket_id")?;
let new_status = super::get_str(&args, "status")?;
let parsed_status = parse_ticket_phase(new_status)?;
let store = crate::board::store();
// Guard: refuse to update a ticket that is in a pipeline-blocking phase.
guard_not_pipeline_blocking(store, ticket_id).await?;
store.transition_to(ticket_id, None, parsed_status).await?;
Ok(format!(
"Ticket {ticket_id} status updated to '{new_status}'"
))
}
}
// ── ListTicketsTool ─────────────────────────────────────────────
pub struct ListTicketsTool;
#[async_trait]
impl Tool for ListTicketsTool {
fn name(&self) -> &'static str {
"list_tickets"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"status": {
"type": "string",
"description": "Optional status filter (e.g. 'ready_for_development', 'in_development', 'done', 'cancelled'). When omitted, 'done' and 'cancelled' tickets are excluded — use an explicit status filter to include them. Use 'search_archived_tickets' to find archived tickets."
}
}),
&[],
)
}
fn side_effects(&self, _args: &serde_json::Value) -> bool {
false // read-only board query
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> Result<String> {
let raw_status = super::get_opt_str(&args, "status");
// "archived" is no longer a status — redirect to search_archived_tickets
if let Some(s) = raw_status
&& s.eq_ignore_ascii_case("archived")
{
anyhow::bail!(
"Archived tickets are not accessible via 'list_tickets'. \
Use 'search_archived_tickets' to search the archive."
);
}
let status_filter = match raw_status {
Some(s) => {
let parsed = parse_ticket_phase(s)?;
Some(parsed)
}
None => None,
};
let store = crate::board::store();
let mut tickets = store
.list_all_tickets(Some(&ws.name), status_filter)
.await?;
// Default: exclude unblocking phases. When an explicit status filter
// is provided, respect it as-is.
if status_filter.is_none() {
tickets.retain(|t| !t.status.is_unblocking());
}
if tickets.is_empty() {
return Ok("No tickets found.".to_string());
}
let mut output = String::from("Tickets:\n");
for t in &tickets {
let _ = writeln!(output, "{}", t.short_display());
}
Ok(output)
}
}
// ── GetTicketTool ───────────────────────────────────────────────
pub struct GetTicketTool;
#[async_trait]
impl Tool for GetTicketTool {
fn name(&self) -> &'static str {
"get_ticket"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"ticket_id": {
"type": "string",
"description": "The ticket id"
}
}),
&["ticket_id"],
)
}
fn side_effects(&self, _args: &serde_json::Value) -> bool {
false // read-only board query
}
/// Override format_output to preserve full ticket content.
/// The default truncation at 5K chars would lose long comments/descriptions
/// that LLMs need to read in their entirety.
fn format_output(&self, output: &str) -> String {
output.to_string()
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> Result<String> {
let ticket_id = super::get_str(&args, "ticket_id")?;
let store = crate::board::store();
match store.get_ticket(ticket_id).await? {
Some(ticket) => Ok(ticket.detailed_display()),
None => anyhow::bail!("Ticket {ticket_id} not found"),
}
}
}
// ── AddCommentTool ──────────────────────────────────────────────
pub struct AddCommentTool;
#[async_trait]
impl Tool for AddCommentTool {
fn name(&self) -> &'static str {
"add_comment"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"ticket_id": {
"type": "string",
"description": "The ID of the ticket to comment on"
},
"content": {
"type": "string",
"description": "The comment content"
}
}),
&["ticket_id", "content"],
)
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> Result<String> {
let ticket_id = super::get_str(&args, "ticket_id")?;
let content = super::get_str(&args, "content")?;
let store = crate::board::store();
// Guard: refuse to comment on a ticket that is in a pipeline-blocking phase.
guard_not_pipeline_blocking(store, ticket_id).await?;
store
.add_comment(ticket_id, Role::Manager.as_str(), content)
.await?;
Ok(format!("Comment added to ticket {ticket_id}"))
}
}
/// Returns a comma-separated string of all valid [`TicketPhase`] variant names.
///
/// Used in parse-error messages across ticket tools to give agents an
/// actionable list of accepted status strings.
fn valid_phases_string() -> String {
<TicketPhase as strum::IntoEnumIterator>::iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ")
}
/// Parse a status string into a [`TicketPhase`], returning a user-friendly
/// error message listing all valid phases on failure.
fn parse_ticket_phase(s: &str) -> Result<TicketPhase> {
s.parse::<TicketPhase>().map_err(|_| {
anyhow::anyhow!(
"Invalid status '{s}'. Valid statuses: {}",
valid_phases_string()
)
})
}
/// Guard: refuse to proceed if the ticket is in a pipeline-blocking phase.
/// All three user-facing ticket tools use this to prevent modifications
/// to in-flight tickets during automated pipeline processing.
async fn guard_not_pipeline_blocking(
store: &crate::board::BoardStore,
ticket_id: &str,
) -> Result<()> {
if let Some(current_phase) = store.get_ticket_status(ticket_id).await?
&& current_phase.is_pipeline_blocking()
{
anyhow::bail!(
"Ticket {ticket_id} is currently in phase '{status}' — \
in-flight tickets cannot be modified through automated tools. \
Use the GUI to cancel or manage this ticket manually.",
status = current_phase.as_ref(),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::test_ws;
use serde_json::json;
// ── Unit test for parse_ticket_phase error formatting ──────────
#[test]
fn test_parse_ticket_phase_error_message() {
let err = parse_ticket_phase("bogus_status").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Invalid status"),
"error should mention 'Invalid status'"
);
assert!(
msg.contains("bogus_status"),
"error should contain the invalid input value"
);
assert!(
msg.contains("backlog"),
"error should list valid phases (e.g. backlog)"
);
assert!(
msg.contains("done"),
"error should list valid phases (e.g. done)"
);
}
#[tokio::test]
async fn test_create_ticket_tool() {
crate::util::test::init_test_stores().await;
let tool = CreateTicketTool::new("test");
let ws = test_ws("/tmp/test_ws");
let args = json!({
"title": "Test ticket",
"description": "A test description",
});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains("Test ticket"),
"Output should contain title"
);
}
#[tokio::test]
async fn test_update_ticket_tool() {
crate::util::test::init_test_stores().await;
let store = crate::board::store();
let id = store
.create_ticket(
"Test",
"desc",
&test_ws("/ws"),
crate::board::DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
let tool = UpdateTicketTool;
let ws = test_ws("/tmp");
let args = json!({
"ticket_id": id,
"status": "in_qa"
});
tool.execute(&ws, args).await.expect("execute");
let ticket = store
.get_ticket(&id)
.await
.expect("get_ticket")
.expect("ticket exists");
assert_eq!(ticket.status, TicketPhase::InQa);
}
#[tokio::test]
async fn test_list_tickets_tool() {
crate::util::test::init_test_stores().await;
let store = crate::board::store();
let ws = test_ws("/tmp_ws");
let _id_a = store
.create_ticket(
"A",
"desc",
&ws,
crate::board::DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
let id_b = store
.create_ticket(
"B",
"desc",
&ws,
crate::board::DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
let id_c = store
.create_ticket(
"C",
"desc",
&ws,
crate::board::DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
// Transition C to 'done' and B to 'cancelled'
store
.transition_to(&id_c, Some(TicketPhase::Backlog), TicketPhase::Done)
.await
.expect("transition to done");
store
.transition_to(&id_b, Some(TicketPhase::Backlog), TicketPhase::Cancelled)
.await
.expect("transition to cancelled");
let tool = ListTicketsTool;
// Default (no filter): excludes done and cancelled — only A should appear
let args = json!({});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains('A'),
"Default listing should include active ticket A"
);
assert!(
!result.contains('B'),
"Default listing should exclude cancelled ticket B"
);
assert!(
!result.contains('C'),
"Default listing should exclude done ticket C"
);
// Explicit filter for 'done': includes done tickets
let args = json!({"status": "done"});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains('C'),
"Explicit 'done' filter should include done ticket C"
);
assert!(
!result.contains('A'),
"Explicit 'done' filter should exclude active ticket A"
);
// Explicit filter for 'cancelled': includes cancelled tickets
let args = json!({"status": "cancelled"});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains('B'),
"Explicit 'cancelled' filter should include cancelled ticket B"
);
// Filter for an active phase that ticket A is actually in
let args = json!({"status": "backlog"});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains('A'),
"Explicit 'backlog' filter should include ticket A"
);
}
#[tokio::test]
async fn test_list_tickets_invalid_status() {
crate::util::test::init_test_stores().await;
let ws = crate::workspace::test_ws("/tmp");
let tool = ListTicketsTool;
let args = json!({"status": "bogus_status"});
let result = tool.execute(&ws, args).await;
assert!(result.is_err(), "Invalid status should fail");
}
#[tokio::test]
async fn test_get_ticket_tool() {
crate::util::test::init_test_stores().await;
let store = crate::board::store();
let id = store
.create_ticket(
"GetTest",
"get me",
&test_ws("/ws"),
crate::board::DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
// Fetch the ticket to compare against detailed_display()
let ticket = crate::util::test::expect_ticket(store, &id).await;
let expected = ticket.detailed_display();
let tool = GetTicketTool;
let ws = test_ws("/tmp");
let args = json!({"ticket_id": id});
let result = tool.execute(&ws, args).await.expect("execute");
assert_eq!(
result, expected,
"GetTicketTool output must match Ticket::detailed_display()"
);
}
#[tokio::test]
async fn test_add_comment_tool() {
crate::util::test::init_test_stores().await;
let store = crate::board::store();
let id = store
.create_ticket(
"CommentTest",
"add a comment",
&test_ws("/ws"),
crate::board::DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
let tool = AddCommentTool;
let ws = test_ws("/tmp");
let args = json!({
"ticket_id": id,
"content": "This is a test comment",
});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(result.contains(&id), "Output should contain ticket id");
// Verify comment was stored
let ticket = crate::util::test::expect_ticket(store, &id).await;
let comment = ticket.comments.last().expect("at least one comment");
assert_eq!(comment.role, Role::Manager.as_str());
assert_eq!(comment.content, "This is a test comment");
}
#[tokio::test]
async fn test_invalid_status() {
crate::util::test::init_test_stores().await;
let store = crate::board::store();
let id = store
.create_ticket(
"Test",
"desc",
&test_ws("/ws"),
crate::board::DEFAULT_TICKET_PHASE,
&[],
"test",
None,
)
.await
.expect("create");
let tool = UpdateTicketTool;
let ws = test_ws("/tmp");
let args = json!({
"ticket_id": id,
"status": "invalid_status"
});
let result = tool.execute(&ws, args).await;
assert!(result.is_err(), "Invalid status should fail");
}
// ── Pipeline-blocking guard tests ────────────────────────────
#[tokio::test]
async fn test_guard_not_pipeline_blocking_cases() {
crate::util::test::init_test_stores().await;
let store = crate::board::store();
let ws = test_ws("/ws");
let blocking_id = store
.create_ticket(
"BlockingGuard",
"in development",
&ws,
TicketPhase::InDevelopment,
&[],
"test",
None,
)
.await
.expect("create");
let nonblocking_id = store
.create_ticket(
"NonBlockingGuard",
"backlog",
&ws,
TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create");
let result = guard_not_pipeline_blocking(store, &blocking_id).await;
assert!(result.is_err(), "Blocking-phase ticket should be rejected");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("in-flight"),
"Error should mention in-flight restriction"
);
let result = guard_not_pipeline_blocking(store, &nonblocking_id).await;
assert!(result.is_ok(), "Non-blocking ticket should pass the guard");
// Non-existent ticket silently passes (nothing to block)
let result = guard_not_pipeline_blocking(store, "nonexistent_id").await;
assert!(
result.is_ok(),
"Non-existent ticket should silently pass the guard"
);
}
/// Create a ticket in a pipeline-blocking phase (InDevelopment).
/// Used by wiring tests below to verify tools call `guard_not_pipeline_blocking`.
async fn create_blocking_ticket() -> String {
crate::util::test::init_test_stores().await;
let store = crate::board::store();
store
.create_ticket(
"BlockMe",
"in flight",
&test_ws("/ws"),
TicketPhase::InDevelopment,
&[],
"test",
None,
)
.await
.expect("create")
}
#[tokio::test]
async fn test_update_ticket_blocked_by_pipeline() {
let id = create_blocking_ticket().await;
let result = UpdateTicketTool
.execute(
&test_ws("/tmp"),
json!({"ticket_id": id, "status": "backlog"}),
)
.await;
assert!(
result.is_err(),
"Pipeline-blocking ticket should reject update"
);
}
#[tokio::test]
async fn test_create_ticket_supersede_blocked_by_pipeline() {
let id = create_blocking_ticket().await;
let result = CreateTicketTool::new("test")
.execute(
&test_ws("/ws"),
json!({"title": "Replacement", "description": "trying to supersede", "supersede": id}),
)
.await;
assert!(
result.is_err(),
"Supersede should be rejected for pipeline-blocking tickets"
);
}
#[tokio::test]
async fn test_add_comment_blocked_by_pipeline() {
let id = create_blocking_ticket().await;
let result = AddCommentTool
.execute(
&test_ws("/tmp"),
json!({"ticket_id": id, "content": "This should be blocked"}),
)
.await;
assert!(
result.is_err(),
"Pipeline-blocking ticket should reject comments"
);
}
#[tokio::test]
async fn test_update_ticket_allowed_when_not_blocked() {
crate::util::test::init_test_stores().await;
let store = crate::board::store();
let id = store
.create_ticket(
"NotBlocked",
"backlog ticket",
&test_ws("/ws"),
TicketPhase::Backlog,
&[],
"test",
None,
)
.await
.expect("create");
let tool = UpdateTicketTool;
let ws = test_ws("/tmp");
let args = json!({
"ticket_id": id,
"status": "done"
});
let result = tool.execute(&ws, args).await;
assert!(
result.is_ok(),
"Non-pipeline ticket should update freely: {:?}",
result.err()
);
}
}