merge: add ticket feature tools
This commit is contained in:
commit
4486a8133e
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -2350,6 +2350,7 @@ dependencies = [
|
||||||
"session-store",
|
"session-store",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"ticket",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tools",
|
"tools",
|
||||||
|
|
@ -3621,10 +3622,16 @@ dependencies = [
|
||||||
name = "ticket"
|
name = "ticket"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"fs4",
|
"fs4",
|
||||||
|
"llm-worker",
|
||||||
|
"schemars",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ include_dir = "0.7.4"
|
||||||
fs4 = { workspace = true, features = ["sync"] }
|
fs4 = { workspace = true, features = ["sync"] }
|
||||||
libc = { workspace = true }
|
libc = { workspace = true }
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
|
ticket = { workspace = true }
|
||||||
memory = { workspace = true }
|
memory = { workspace = true }
|
||||||
workflow-crate = { package = "workflow", path = "../workflow" }
|
workflow-crate = { package = "workflow", path = "../workflow" }
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,7 @@ where
|
||||||
|
|
||||||
let mut feature_registry = FeatureRegistryBuilder::new();
|
let mut feature_registry = FeatureRegistryBuilder::new();
|
||||||
feature_registry.add_module(task_feature);
|
feature_registry.add_module(task_feature);
|
||||||
|
feature_registry.add_module(crate::feature::builtin::ticket_tools_feature(&pwd));
|
||||||
let _feature_install_report = pod.install_features(feature_registry);
|
let _feature_install_report = pod.install_features(feature_registry);
|
||||||
|
|
||||||
let worker = pod.worker_mut();
|
let worker = pod.worker_mut();
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ pub enum HostAuthority {
|
||||||
ModelNotification,
|
ModelNotification,
|
||||||
PodManagement,
|
PodManagement,
|
||||||
StateStore { name: String },
|
StateStore { name: String },
|
||||||
|
TicketBackend { root: String },
|
||||||
ServiceAccess { service: ServiceId },
|
ServiceAccess { service: ServiceId },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,7 @@
|
||||||
//! an external plugin-loading surface.
|
//! an external plugin-loading surface.
|
||||||
|
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
pub mod ticket;
|
||||||
|
|
||||||
pub use task::{TaskFeature, task_tools_feature};
|
pub use task::{TaskFeature, task_tools_feature};
|
||||||
|
pub use ticket::{TicketFeature, ticket_tools_feature};
|
||||||
|
|
|
||||||
224
crates/pod/src/feature/builtin/ticket.rs
Normal file
224
crates/pod/src/feature/builtin/ticket.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
//! Built-in Ticket feature adapter.
|
||||||
|
//!
|
||||||
|
//! The ticket crate owns Ticket domain logic and Tool implementations. This
|
||||||
|
//! module only resolves the local backend root, declares the built-in feature,
|
||||||
|
//! and contributes those tools through the normal feature registry path.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use ticket::{LocalTicketBackend, tool::TICKET_TOOL_NAMES, tool::ticket_tools};
|
||||||
|
|
||||||
|
use crate::feature::{
|
||||||
|
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||||
|
FeatureModule, HostAuthority, HostAuthorityRequest, ToolContribution, ToolDeclaration,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_ID: &str = "ticket";
|
||||||
|
const FEATURE_NAME: &str = "Ticket tools";
|
||||||
|
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
||||||
|
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
||||||
|
const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for typed work-item operations without generic filesystem write authority.";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TicketFeature {
|
||||||
|
backend_root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TicketFeature {
|
||||||
|
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
backend_root: backend_root.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
|
||||||
|
Self::new(workspace.as_ref().join("work-items"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backend_root(&self) -> &Path {
|
||||||
|
&self.backend_root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authority(&self) -> HostAuthority {
|
||||||
|
HostAuthority::TicketBackend {
|
||||||
|
root: self.backend_root.display().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
||||||
|
let root = self
|
||||||
|
.backend_root
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| format!("ticket backend root is not usable: {error}"))?;
|
||||||
|
if !root.is_dir() {
|
||||||
|
return Err("ticket backend root is not a directory".to_string());
|
||||||
|
}
|
||||||
|
for status_dir in ["open", "pending", "closed"] {
|
||||||
|
let dir = root.join(status_dir);
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"ticket backend root is missing required {status_dir}/ directory"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for TicketFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||||
|
.with_description(FEATURE_DESCRIPTION)
|
||||||
|
.with_host_authority(HostAuthorityRequest::required(
|
||||||
|
self.authority(),
|
||||||
|
AUTHORITY_REASON,
|
||||||
|
));
|
||||||
|
for name in TICKET_TOOL_NAMES {
|
||||||
|
descriptor = descriptor.with_tool(ToolDeclaration::new(name, tool_description(name)));
|
||||||
|
}
|
||||||
|
descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
let usable_root = match self.usable_backend_root() {
|
||||||
|
Ok(root) => root,
|
||||||
|
Err(reason) => {
|
||||||
|
context
|
||||||
|
.diagnostics()
|
||||||
|
.push(FeatureDiagnostic::warning(format!(
|
||||||
|
"Ticket tools not registered: {reason}; root={} ",
|
||||||
|
self.backend_root.display()
|
||||||
|
)));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let authority = self.authority();
|
||||||
|
let backend = LocalTicketBackend::new(usable_root);
|
||||||
|
let mut tools = context.tools();
|
||||||
|
for definition in ticket_tools(backend) {
|
||||||
|
let (meta, _) = definition();
|
||||||
|
let name = meta.name.clone();
|
||||||
|
tools.register(
|
||||||
|
ToolContribution::new(name, definition)
|
||||||
|
.with_required_host_authorities(vec![authority.clone()]),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.",
|
||||||
|
"TicketShow" => {
|
||||||
|
"Show one Ticket through the typed local Ticket backend with bounded output."
|
||||||
|
}
|
||||||
|
"TicketComment" => {
|
||||||
|
"Append a comment/plan/decision/implementation_report event to a Ticket."
|
||||||
|
}
|
||||||
|
"TicketReview" => "Append an approve/request_changes review event to a Ticket.",
|
||||||
|
"TicketStatus" => "Move a Ticket between open and pending; use TicketClose for closed.",
|
||||||
|
"TicketClose" => "Close a Ticket with a resolution through the typed local Ticket backend.",
|
||||||
|
"TicketDoctor" => "Run typed local Ticket backend consistency checks.",
|
||||||
|
_ => "Typed Ticket backend tool.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature {
|
||||||
|
TicketFeature::for_workspace(workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::feature::{FeatureRegistryBuilder, FeatureRuntimeKind};
|
||||||
|
use crate::hook::HookRegistryBuilder;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn make_work_items(root: &Path) {
|
||||||
|
std::fs::create_dir_all(root.join("open")).unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("pending")).unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("closed")).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descriptor_declares_ticket_tools_and_backend_authority() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let feature = ticket_tools_feature(temp.path());
|
||||||
|
let descriptor = feature.descriptor();
|
||||||
|
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
|
||||||
|
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||||
|
assert_eq!(descriptor.tools.len(), TICKET_TOOL_NAMES.len());
|
||||||
|
assert_eq!(
|
||||||
|
descriptor
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
TICKET_TOOL_NAMES
|
||||||
|
);
|
||||||
|
assert_eq!(descriptor.requested_host_authorities.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
descriptor.requested_host_authorities[0].authority,
|
||||||
|
HostAuthority::TicketBackend { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installs_ticket_tools_when_work_items_root_is_usable() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
make_work_items(&temp.path().join("work-items"));
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let mut hooks = HookRegistryBuilder::default();
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(ticket_tools_feature(temp.path()))
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
|
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||||
|
assert_eq!(report.reports.len(), 1);
|
||||||
|
assert!(report.reports[0].installed);
|
||||||
|
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||||
|
assert!(report.reports[0].skipped.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_register_ticket_tools_when_root_is_missing() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let mut hooks = HookRegistryBuilder::default();
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(ticket_tools_feature(temp.path()))
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
|
assert!(pending_tools.is_empty());
|
||||||
|
assert_eq!(report.reports.len(), 1);
|
||||||
|
assert!(report.reports[0].installed);
|
||||||
|
assert!(report.reports[0].installed_tools.is_empty());
|
||||||
|
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
||||||
|
assert!(
|
||||||
|
report.reports[0].diagnostics[0]
|
||||||
|
.message
|
||||||
|
.contains("Ticket tools not registered")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_register_ticket_tools_when_root_lacks_status_dirs() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(temp.path().join("work-items")).unwrap();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let mut hooks = HookRegistryBuilder::default();
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(ticket_tools_feature(temp.path()))
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
|
||||||
|
assert!(pending_tools.is_empty());
|
||||||
|
assert!(report.reports[0].installed_tools.is_empty());
|
||||||
|
assert!(
|
||||||
|
report.reports[0].diagnostics[0]
|
||||||
|
.message
|
||||||
|
.contains("missing required open/ directory")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,15 @@ edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
fs4 = { workspace = true, features = ["sync"] }
|
fs4 = { workspace = true, features = ["sync"] }
|
||||||
|
llm-worker = { workspace = true }
|
||||||
|
schemars = { workspace = true }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ use chrono::Utc;
|
||||||
use fs4::fs_std::FileExt;
|
use fs4::fs_std::FileExt;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub mod tool;
|
||||||
|
|
||||||
const STATUSES: [TicketStatus; 3] = [
|
const STATUSES: [TicketStatus; 3] = [
|
||||||
TicketStatus::Open,
|
TicketStatus::Open,
|
||||||
TicketStatus::Pending,
|
TicketStatus::Pending,
|
||||||
|
|
@ -853,10 +855,16 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
}
|
}
|
||||||
fs::rename(&old_dir, &new_dir).map_err(|e| io_err(&new_dir, e))?;
|
fs::rename(&old_dir, &new_dir).map_err(|e| io_err(&new_dir, e))?;
|
||||||
}
|
}
|
||||||
let at = now_utc();
|
self.set_frontmatter_fields(&new_dir.join("item.md"), &[("status", status.as_str())])?;
|
||||||
self.set_frontmatter_fields(
|
let author = default_author();
|
||||||
&new_dir.join("item.md"),
|
let body = MarkdownText::new(format!("Status changed to `{}`.\n", status.as_str()));
|
||||||
&[("status", status.as_str()), ("updated_at", &at)],
|
self.append_thread_event(
|
||||||
|
&new_dir,
|
||||||
|
"status_changed",
|
||||||
|
"Status changed",
|
||||||
|
&author,
|
||||||
|
Some(status.as_str()),
|
||||||
|
&body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
959
crates/ticket/src/tool.rs
Normal file
959
crates/ticket/src/tool.rs
Normal file
|
|
@ -0,0 +1,959 @@
|
||||||
|
//! LLM tool implementations for typed Ticket backend operations.
|
||||||
|
//!
|
||||||
|
//! These tools are intentionally owned by the `ticket` crate so Pod features can
|
||||||
|
//! install Ticket behavior without reimplementing domain/backend logic or
|
||||||
|
//! granting generic filesystem write authority.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket,
|
||||||
|
TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError,
|
||||||
|
TicketEventKind, TicketIdOrSlug, TicketRef, TicketReview, TicketReviewResult, TicketStatus,
|
||||||
|
TicketSummary,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LIST_LIMIT: usize = 100;
|
||||||
|
const MAX_LIST_LIMIT: usize = 200;
|
||||||
|
const DEFAULT_EVENT_LIMIT: usize = 20;
|
||||||
|
const MAX_EVENT_LIMIT: usize = 100;
|
||||||
|
const DEFAULT_ARTIFACT_LIMIT: usize = 50;
|
||||||
|
const MAX_ARTIFACT_LIMIT: usize = 200;
|
||||||
|
const DEFAULT_BODY_MAX_BYTES: usize = 16 * 1024;
|
||||||
|
const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
||||||
|
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||||
|
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||||
|
|
||||||
|
pub const TICKET_TOOL_NAMES: [&str; 8] = [
|
||||||
|
"TicketCreate",
|
||||||
|
"TicketList",
|
||||||
|
"TicketShow",
|
||||||
|
"TicketComment",
|
||||||
|
"TicketReview",
|
||||||
|
"TicketStatus",
|
||||||
|
"TicketClose",
|
||||||
|
"TicketDoctor",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
|
||||||
|
Inputs mirror the work-items item.md fields; `title` is required, `body` is Markdown, and the \
|
||||||
|
backend assigns the id and writes tickets.sh-compatible files under the configured backend root.";
|
||||||
|
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend. Filter by \
|
||||||
|
status (`open`, `pending`, `closed`, or `all`) and optionally kind/priority/label. Output is a \
|
||||||
|
bounded JSON summary list, not full ticket bodies.";
|
||||||
|
const SHOW_DESCRIPTION: &str = "Show one Ticket by id, slug, or exact query through the configured \
|
||||||
|
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
|
||||||
|
artifact metadata.";
|
||||||
|
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket thread event. `role` must be `comment`, \
|
||||||
|
`plan`, `decision`, or `implementation_report`; `body` is Markdown. Writes stay inside the \
|
||||||
|
configured Ticket backend root.";
|
||||||
|
const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
|
||||||
|
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
|
||||||
|
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \
|
||||||
|
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
|
||||||
|
by `tickets.sh doctor`.";
|
||||||
|
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
||||||
|
backend. The backend moves the Ticket to closed/, writes resolution.md, updates item.md, and appends \
|
||||||
|
a close event.";
|
||||||
|
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
|
||||||
|
diagnostics. This does not shell out to tickets.sh.";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketCreateParams {
|
||||||
|
/// Ticket title. Must not be empty.
|
||||||
|
title: String,
|
||||||
|
/// Optional slug seed. The local backend slugifies this value.
|
||||||
|
#[serde(default)]
|
||||||
|
slug: Option<String>,
|
||||||
|
/// Ticket kind. Defaults to `task`.
|
||||||
|
#[serde(default)]
|
||||||
|
kind: Option<String>,
|
||||||
|
/// Ticket priority. Defaults to `P2`.
|
||||||
|
#[serde(default)]
|
||||||
|
priority: Option<String>,
|
||||||
|
/// Ticket labels.
|
||||||
|
#[serde(default)]
|
||||||
|
labels: Vec<String>,
|
||||||
|
/// Markdown body for item.md. If omitted, a small default body is used.
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
/// Optional thread author for the create event.
|
||||||
|
#[serde(default)]
|
||||||
|
author: Option<String>,
|
||||||
|
/// Optional assignee frontmatter value.
|
||||||
|
#[serde(default)]
|
||||||
|
assignee: Option<String>,
|
||||||
|
/// Optional legacy ticket reference frontmatter value.
|
||||||
|
#[serde(default)]
|
||||||
|
legacy_ticket: Option<String>,
|
||||||
|
/// Optional readiness frontmatter value.
|
||||||
|
#[serde(default)]
|
||||||
|
readiness: Option<String>,
|
||||||
|
/// Optional preflight flag frontmatter value.
|
||||||
|
#[serde(default)]
|
||||||
|
needs_preflight: Option<bool>,
|
||||||
|
/// Optional risk flag frontmatter values.
|
||||||
|
#[serde(default)]
|
||||||
|
risk_flags: Vec<String>,
|
||||||
|
/// Optional action-required frontmatter value.
|
||||||
|
#[serde(default)]
|
||||||
|
action_required: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum TicketListStatusParam {
|
||||||
|
Open,
|
||||||
|
Pending,
|
||||||
|
Closed,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketListParams {
|
||||||
|
/// Status filter. Defaults to `open`; use `all` to include closed and pending Tickets.
|
||||||
|
#[serde(default)]
|
||||||
|
status: Option<TicketListStatusParam>,
|
||||||
|
/// Maximum number of summaries to return. Defaults to 100, max 200.
|
||||||
|
#[serde(default)]
|
||||||
|
limit: Option<usize>,
|
||||||
|
/// Optional exact kind filter.
|
||||||
|
#[serde(default)]
|
||||||
|
kind: Option<String>,
|
||||||
|
/// Optional exact priority filter.
|
||||||
|
#[serde(default)]
|
||||||
|
priority: Option<String>,
|
||||||
|
/// Optional label that must be present.
|
||||||
|
#[serde(default)]
|
||||||
|
label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketShowParams {
|
||||||
|
/// Ticket id. Exactly one of `id`, `slug`, or `query` must be provided.
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<String>,
|
||||||
|
/// Ticket slug. Exactly one of `id`, `slug`, or `query` must be provided.
|
||||||
|
#[serde(default)]
|
||||||
|
slug: Option<String>,
|
||||||
|
/// Exact id-or-slug query. Exactly one of `id`, `slug`, or `query` must be provided.
|
||||||
|
#[serde(default)]
|
||||||
|
query: Option<String>,
|
||||||
|
/// Maximum number of most-recent thread events to return. Defaults to 20, max 100.
|
||||||
|
#[serde(default)]
|
||||||
|
event_limit: Option<usize>,
|
||||||
|
/// Maximum number of artifact metadata entries to return. Defaults to 50, max 200.
|
||||||
|
#[serde(default)]
|
||||||
|
artifact_limit: Option<usize>,
|
||||||
|
/// Maximum bytes for each Markdown body field before adding a truncation marker. Defaults to 16 KiB, max 64 KiB.
|
||||||
|
#[serde(default)]
|
||||||
|
body_max_bytes: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum TicketCommentRoleParam {
|
||||||
|
Comment,
|
||||||
|
Plan,
|
||||||
|
Decision,
|
||||||
|
ImplementationReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketCommentParams {
|
||||||
|
/// Ticket id or slug.
|
||||||
|
ticket: String,
|
||||||
|
/// Thread event role: `comment`, `plan`, `decision`, or `implementation_report`.
|
||||||
|
role: TicketCommentRoleParam,
|
||||||
|
/// Markdown event body.
|
||||||
|
body: String,
|
||||||
|
/// Optional thread author.
|
||||||
|
#[serde(default)]
|
||||||
|
author: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum TicketReviewResultParam {
|
||||||
|
Approve,
|
||||||
|
RequestChanges,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketReviewParams {
|
||||||
|
/// Ticket id or slug.
|
||||||
|
ticket: String,
|
||||||
|
/// Review result: `approve` or `request_changes`.
|
||||||
|
result: TicketReviewResultParam,
|
||||||
|
/// Markdown review body.
|
||||||
|
body: String,
|
||||||
|
/// Optional thread author.
|
||||||
|
#[serde(default)]
|
||||||
|
author: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum TicketStatusParam {
|
||||||
|
Open,
|
||||||
|
Pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketStatusParams {
|
||||||
|
/// Ticket id or slug.
|
||||||
|
ticket: String,
|
||||||
|
/// New status. Use `TicketClose` for `closed`.
|
||||||
|
status: TicketStatusParam,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketCloseParams {
|
||||||
|
/// Ticket id or slug.
|
||||||
|
ticket: String,
|
||||||
|
/// Markdown resolution written to resolution.md and thread.md.
|
||||||
|
resolution: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct TicketDoctorParams {
|
||||||
|
/// Maximum diagnostics to return. Defaults to 100, max 500.
|
||||||
|
#[serde(default)]
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct TicketRefOutput {
|
||||||
|
id: String,
|
||||||
|
slug: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct TicketListOutput {
|
||||||
|
status_filter: String,
|
||||||
|
count: usize,
|
||||||
|
returned: usize,
|
||||||
|
truncated: bool,
|
||||||
|
tickets: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct TicketDoctorOutput {
|
||||||
|
ok: bool,
|
||||||
|
error_count: usize,
|
||||||
|
diagnostic_count: usize,
|
||||||
|
returned: usize,
|
||||||
|
truncated: bool,
|
||||||
|
diagnostics: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketCreateTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketListTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketShowTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketCommentTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketReviewTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketStatusTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketCloseTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TicketDoctorTool {
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketCreateTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketCreateParams = parse_input("TicketCreate", input_json)?;
|
||||||
|
let mut input = NewTicket::new(params.title);
|
||||||
|
input.slug = params.slug;
|
||||||
|
if let Some(kind) = params.kind {
|
||||||
|
input.kind = kind;
|
||||||
|
}
|
||||||
|
if let Some(priority) = params.priority {
|
||||||
|
input.priority = priority;
|
||||||
|
}
|
||||||
|
input.labels = params.labels;
|
||||||
|
if let Some(body) = params.body {
|
||||||
|
input.body = MarkdownText::new(body);
|
||||||
|
}
|
||||||
|
input.author = params.author;
|
||||||
|
input.assignee = params.assignee;
|
||||||
|
input.legacy_ticket = params.legacy_ticket;
|
||||||
|
input.readiness = params.readiness;
|
||||||
|
input.needs_preflight = params.needs_preflight;
|
||||||
|
input.risk_flags = params.risk_flags;
|
||||||
|
input.action_required = params.action_required;
|
||||||
|
|
||||||
|
let created = self
|
||||||
|
.backend
|
||||||
|
.create(input)
|
||||||
|
.map_err(|error| backend_error("TicketCreate", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!(
|
||||||
|
"Created ticket {} ({}) status {}",
|
||||||
|
created.id,
|
||||||
|
created.slug,
|
||||||
|
created.status.as_str()
|
||||||
|
),
|
||||||
|
ticket_ref_output(created),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketListTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketListParams = parse_input("TicketList", input_json)?;
|
||||||
|
let status = params.status.unwrap_or(TicketListStatusParam::Open);
|
||||||
|
let filter = match status {
|
||||||
|
TicketListStatusParam::Open => crate::TicketFilter::status(TicketStatus::Open),
|
||||||
|
TicketListStatusParam::Pending => crate::TicketFilter::status(TicketStatus::Pending),
|
||||||
|
TicketListStatusParam::Closed => crate::TicketFilter::status(TicketStatus::Closed),
|
||||||
|
TicketListStatusParam::All => crate::TicketFilter::all(),
|
||||||
|
};
|
||||||
|
let status_filter = match status {
|
||||||
|
TicketListStatusParam::Open => "open",
|
||||||
|
TicketListStatusParam::Pending => "pending",
|
||||||
|
TicketListStatusParam::Closed => "closed",
|
||||||
|
TicketListStatusParam::All => "all",
|
||||||
|
};
|
||||||
|
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||||
|
let mut tickets = self
|
||||||
|
.backend
|
||||||
|
.list(filter)
|
||||||
|
.map_err(|error| backend_error("TicketList", error))?;
|
||||||
|
tickets.retain(|ticket| {
|
||||||
|
params.kind.as_ref().is_none_or(|kind| ticket.kind == *kind)
|
||||||
|
&& params
|
||||||
|
.priority
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|priority| ticket.priority == *priority)
|
||||||
|
&& params
|
||||||
|
.label
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|label| ticket.labels.iter().any(|item| item == label))
|
||||||
|
});
|
||||||
|
let count = tickets.len();
|
||||||
|
let returned_tickets: Vec<_> = tickets
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(ticket_summary_json)
|
||||||
|
.collect();
|
||||||
|
let output = TicketListOutput {
|
||||||
|
status_filter: status_filter.to_string(),
|
||||||
|
count,
|
||||||
|
returned: returned_tickets.len(),
|
||||||
|
truncated: count > returned_tickets.len(),
|
||||||
|
tickets: returned_tickets,
|
||||||
|
};
|
||||||
|
Ok(json_output(
|
||||||
|
format!(
|
||||||
|
"Listed {} ticket(s) for status {status_filter}{}",
|
||||||
|
output.returned,
|
||||||
|
if output.truncated { " (truncated)" } else { "" }
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketShowTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketShowParams = parse_input("TicketShow", input_json)?;
|
||||||
|
let query = id_or_slug(params.id, params.slug, params.query)?;
|
||||||
|
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
|
||||||
|
let artifact_limit = bounded(
|
||||||
|
params.artifact_limit,
|
||||||
|
DEFAULT_ARTIFACT_LIMIT,
|
||||||
|
MAX_ARTIFACT_LIMIT,
|
||||||
|
);
|
||||||
|
let body_max_bytes = bounded(
|
||||||
|
params.body_max_bytes,
|
||||||
|
DEFAULT_BODY_MAX_BYTES,
|
||||||
|
MAX_BODY_MAX_BYTES,
|
||||||
|
);
|
||||||
|
let ticket = self
|
||||||
|
.backend
|
||||||
|
.show(query)
|
||||||
|
.map_err(|error| backend_error("TicketShow", error))?;
|
||||||
|
let summary = format!(
|
||||||
|
"Ticket {} ({}) status {}",
|
||||||
|
ticket.meta.id,
|
||||||
|
ticket.meta.slug,
|
||||||
|
status_as_str(&ticket.meta.status)
|
||||||
|
);
|
||||||
|
Ok(json_output(
|
||||||
|
summary,
|
||||||
|
ticket_json(&ticket, event_limit, artifact_limit, body_max_bytes),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketCommentTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketCommentParams = parse_input("TicketComment", input_json)?;
|
||||||
|
let kind = match params.role {
|
||||||
|
TicketCommentRoleParam::Comment => TicketEventKind::Comment,
|
||||||
|
TicketCommentRoleParam::Plan => TicketEventKind::Plan,
|
||||||
|
TicketCommentRoleParam::Decision => TicketEventKind::Decision,
|
||||||
|
TicketCommentRoleParam::ImplementationReport => TicketEventKind::ImplementationReport,
|
||||||
|
};
|
||||||
|
let role = kind.as_str().to_string();
|
||||||
|
let mut event = NewTicketEvent::new(kind, params.body);
|
||||||
|
event.author = params.author;
|
||||||
|
self.backend
|
||||||
|
.add_event(TicketIdOrSlug::Query(params.ticket.clone()), event)
|
||||||
|
.map_err(|error| backend_error("TicketComment", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!("Appended {role} event to ticket {}", params.ticket),
|
||||||
|
json!({ "ticket": params.ticket, "event": role, "ok": true }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketReviewTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketReviewParams = parse_input("TicketReview", input_json)?;
|
||||||
|
let result = match params.result {
|
||||||
|
TicketReviewResultParam::Approve => TicketReviewResult::Approve,
|
||||||
|
TicketReviewResultParam::RequestChanges => TicketReviewResult::RequestChanges,
|
||||||
|
};
|
||||||
|
let result_str = result.as_str().to_string();
|
||||||
|
let review = TicketReview {
|
||||||
|
result,
|
||||||
|
author: params.author,
|
||||||
|
body: MarkdownText::new(params.body),
|
||||||
|
};
|
||||||
|
self.backend
|
||||||
|
.review(TicketIdOrSlug::Query(params.ticket.clone()), review)
|
||||||
|
.map_err(|error| backend_error("TicketReview", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!("Appended {result_str} review to ticket {}", params.ticket),
|
||||||
|
json!({ "ticket": params.ticket, "review": result_str, "ok": true }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketStatusTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketStatusParams = parse_input("TicketStatus", input_json)?;
|
||||||
|
let status = match params.status {
|
||||||
|
TicketStatusParam::Open => TicketStatus::Open,
|
||||||
|
TicketStatusParam::Pending => TicketStatus::Pending,
|
||||||
|
};
|
||||||
|
self.backend
|
||||||
|
.set_status(TicketIdOrSlug::Query(params.ticket.clone()), status)
|
||||||
|
.map_err(|error| backend_error("TicketStatus", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!("Moved ticket {} to {}", params.ticket, status.as_str()),
|
||||||
|
json!({ "ticket": params.ticket, "status": status.as_str(), "ok": true }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketCloseTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketCloseParams = parse_input("TicketClose", input_json)?;
|
||||||
|
self.backend
|
||||||
|
.close(
|
||||||
|
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||||
|
MarkdownText::new(params.resolution),
|
||||||
|
)
|
||||||
|
.map_err(|error| backend_error("TicketClose", error))?;
|
||||||
|
Ok(json_output(
|
||||||
|
format!("Closed ticket {}", params.ticket),
|
||||||
|
json!({ "ticket": params.ticket, "status": "closed", "ok": true }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for TicketDoctorTool {
|
||||||
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
let params: TicketDoctorParams = parse_input("TicketDoctor", input_json)?;
|
||||||
|
let limit = bounded(params.limit, DEFAULT_DIAGNOSTIC_LIMIT, MAX_DIAGNOSTIC_LIMIT);
|
||||||
|
let report = self
|
||||||
|
.backend
|
||||||
|
.doctor()
|
||||||
|
.map_err(|error| backend_error("TicketDoctor", error))?;
|
||||||
|
let output = doctor_output(report, limit);
|
||||||
|
Ok(json_output(
|
||||||
|
format!(
|
||||||
|
"Ticket doctor: {} error(s), {} diagnostic(s){}",
|
||||||
|
output.error_count,
|
||||||
|
output.diagnostic_count,
|
||||||
|
if output.truncated { " (truncated)" } else { "" }
|
||||||
|
),
|
||||||
|
output,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
|
||||||
|
serde_json::from_str(input_json)
|
||||||
|
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_error(tool: &str, error: TicketError) -> ToolError {
|
||||||
|
ToolError::ExecutionFailed(format!("{tool} failed: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounded(value: Option<usize>, default: usize, max: usize) -> usize {
|
||||||
|
value.unwrap_or(default).clamp(1, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_or_slug(
|
||||||
|
id: Option<String>,
|
||||||
|
slug: Option<String>,
|
||||||
|
query: Option<String>,
|
||||||
|
) -> Result<TicketIdOrSlug, ToolError> {
|
||||||
|
let provided = id.iter().chain(slug.iter()).chain(query.iter()).count();
|
||||||
|
if provided != 1 {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"exactly one of id, slug, or query must be provided".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(id) = id {
|
||||||
|
Ok(TicketIdOrSlug::Id(id))
|
||||||
|
} else if let Some(slug) = slug {
|
||||||
|
Ok(TicketIdOrSlug::Slug(slug))
|
||||||
|
} else {
|
||||||
|
Ok(TicketIdOrSlug::Query(
|
||||||
|
query.expect("provided count checked"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_as_str(status: &ExtensibleTicketStatus) -> &str {
|
||||||
|
status.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_ref_output(ticket: TicketRef) -> TicketRefOutput {
|
||||||
|
TicketRefOutput {
|
||||||
|
id: ticket.id,
|
||||||
|
slug: ticket.slug,
|
||||||
|
status: ticket.status.as_str().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_summary_json(ticket: TicketSummary) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": ticket.id,
|
||||||
|
"slug": ticket.slug,
|
||||||
|
"title": ticket.title,
|
||||||
|
"status": status_as_str(&ticket.status),
|
||||||
|
"kind": ticket.kind,
|
||||||
|
"priority": ticket.priority,
|
||||||
|
"labels": ticket.labels,
|
||||||
|
"readiness": ticket.readiness,
|
||||||
|
"needs_preflight": ticket.needs_preflight,
|
||||||
|
"action_required": ticket.action_required,
|
||||||
|
"updated_at": ticket.updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_json(
|
||||||
|
ticket: &Ticket,
|
||||||
|
event_limit: usize,
|
||||||
|
artifact_limit: usize,
|
||||||
|
body_max_bytes: usize,
|
||||||
|
) -> Value {
|
||||||
|
let event_count = ticket.events.len();
|
||||||
|
let events: Vec<_> = ticket
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.skip(event_count.saturating_sub(event_limit))
|
||||||
|
.map(|event| {
|
||||||
|
json!({
|
||||||
|
"kind": event.kind.as_str(),
|
||||||
|
"author": event.author,
|
||||||
|
"at": event.at,
|
||||||
|
"status": event.status,
|
||||||
|
"heading": event.heading,
|
||||||
|
"body": truncate_text(event.body.as_str(), body_max_bytes),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let artifact_count = ticket.artifacts.len();
|
||||||
|
let artifacts: Vec<_> = ticket
|
||||||
|
.artifacts
|
||||||
|
.iter()
|
||||||
|
.take(artifact_limit)
|
||||||
|
.map(|artifact| artifact.relative_path.display().to_string())
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"meta": {
|
||||||
|
"id": ticket.meta.id,
|
||||||
|
"slug": ticket.meta.slug,
|
||||||
|
"title": ticket.meta.title,
|
||||||
|
"status": status_as_str(&ticket.meta.status),
|
||||||
|
"kind": ticket.meta.kind,
|
||||||
|
"priority": ticket.meta.priority,
|
||||||
|
"labels": ticket.meta.labels,
|
||||||
|
"created_at": ticket.meta.created_at,
|
||||||
|
"updated_at": ticket.meta.updated_at,
|
||||||
|
"assignee": ticket.meta.assignee,
|
||||||
|
"legacy_ticket": ticket.meta.legacy_ticket,
|
||||||
|
"readiness": ticket.meta.readiness,
|
||||||
|
"needs_preflight": ticket.meta.needs_preflight,
|
||||||
|
"risk_flags": ticket.meta.risk_flags,
|
||||||
|
"action_required": ticket.meta.action_required,
|
||||||
|
},
|
||||||
|
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
|
||||||
|
"events": {
|
||||||
|
"count": event_count,
|
||||||
|
"returned": events.len(),
|
||||||
|
"truncated": event_count > events.len(),
|
||||||
|
"items": events,
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"count": artifact_count,
|
||||||
|
"returned": artifacts.len(),
|
||||||
|
"truncated": artifact_count > artifacts.len(),
|
||||||
|
"items": artifacts,
|
||||||
|
},
|
||||||
|
"resolution": ticket.resolution.as_ref().map(|resolution| truncate_text(resolution.as_str(), body_max_bytes)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doctor_output(report: TicketDoctorReport, limit: usize) -> TicketDoctorOutput {
|
||||||
|
let diagnostic_count = report.diagnostics.len();
|
||||||
|
let error_count = report.error_count();
|
||||||
|
let diagnostics = report
|
||||||
|
.diagnostics
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.map(diagnostic_json)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
TicketDoctorOutput {
|
||||||
|
ok: error_count == 0,
|
||||||
|
error_count,
|
||||||
|
diagnostic_count,
|
||||||
|
returned: diagnostics.len(),
|
||||||
|
truncated: diagnostic_count > diagnostics.len(),
|
||||||
|
diagnostics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostic_json(diagnostic: TicketDoctorDiagnostic) -> Value {
|
||||||
|
let severity = match diagnostic.severity {
|
||||||
|
TicketDoctorSeverity::Error => "error",
|
||||||
|
TicketDoctorSeverity::Warning => "warning",
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"severity": severity,
|
||||||
|
"message": diagnostic.message,
|
||||||
|
"path": diagnostic.path.map(|path| path.display().to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_text(text: &str, max_bytes: usize) -> String {
|
||||||
|
if text.len() <= max_bytes {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
let marker = format!("\n\n[truncated: {} bytes dropped]", text.len() - max_bytes);
|
||||||
|
let mut cut = max_bytes.saturating_sub(marker.len());
|
||||||
|
while cut > 0 && !text.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
let mut out = text[..cut].to_string();
|
||||||
|
out.push_str(&marker);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_output(summary: String, value: impl Serialize) -> ToolOutput {
|
||||||
|
ToolOutput {
|
||||||
|
summary,
|
||||||
|
content: Some(serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_definition<T>(
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
backend: LocalTicketBackend,
|
||||||
|
) -> ToolDefinition
|
||||||
|
where
|
||||||
|
T: Tool + From<LocalTicketBackend> + 'static,
|
||||||
|
{
|
||||||
|
Arc::new(move || {
|
||||||
|
let schema_value = input_schema(name);
|
||||||
|
let meta = ToolMeta::new(name)
|
||||||
|
.description(description)
|
||||||
|
.input_schema(schema_value);
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(T::from(backend.clone()));
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_schema(name: &str) -> Value {
|
||||||
|
match name {
|
||||||
|
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
|
||||||
|
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
|
||||||
|
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
|
||||||
|
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
|
||||||
|
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
|
||||||
|
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
|
||||||
|
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
||||||
|
"TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)),
|
||||||
|
_ => Ok(json!({})),
|
||||||
|
}
|
||||||
|
.unwrap_or_else(|_| json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_from_backend {
|
||||||
|
($tool:ident) => {
|
||||||
|
impl From<LocalTicketBackend> for $tool {
|
||||||
|
fn from(backend: LocalTicketBackend) -> Self {
|
||||||
|
Self { backend }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_from_backend!(TicketCreateTool);
|
||||||
|
impl_from_backend!(TicketListTool);
|
||||||
|
impl_from_backend!(TicketShowTool);
|
||||||
|
impl_from_backend!(TicketCommentTool);
|
||||||
|
impl_from_backend!(TicketReviewTool);
|
||||||
|
impl_from_backend!(TicketStatusTool);
|
||||||
|
impl_from_backend!(TicketCloseTool);
|
||||||
|
impl_from_backend!(TicketDoctorTool);
|
||||||
|
|
||||||
|
/// Build all MVP Ticket tool definitions over one local backend root.
|
||||||
|
pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
|
||||||
|
vec![
|
||||||
|
tool_definition::<TicketCreateTool>("TicketCreate", CREATE_DESCRIPTION, backend.clone()),
|
||||||
|
tool_definition::<TicketListTool>("TicketList", LIST_DESCRIPTION, backend.clone()),
|
||||||
|
tool_definition::<TicketShowTool>("TicketShow", SHOW_DESCRIPTION, backend.clone()),
|
||||||
|
tool_definition::<TicketCommentTool>("TicketComment", COMMENT_DESCRIPTION, backend.clone()),
|
||||||
|
tool_definition::<TicketReviewTool>("TicketReview", REVIEW_DESCRIPTION, backend.clone()),
|
||||||
|
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
|
||||||
|
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
|
||||||
|
tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn backend(temp: &TempDir) -> LocalTicketBackend {
|
||||||
|
LocalTicketBackend::new(temp.path().join("work-items"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
|
||||||
|
let (_, tool) = definition();
|
||||||
|
tool
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_by_name(backend: LocalTicketBackend, name: &str) -> Arc<dyn Tool> {
|
||||||
|
ticket_tools(backend)
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|definition| {
|
||||||
|
let (meta, tool) = definition();
|
||||||
|
(meta.name == name).then_some(tool)
|
||||||
|
})
|
||||||
|
.expect("tool exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ticket_tools_create_list_show_and_doctor() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let create = tool_by_name(backend.clone(), "TicketCreate");
|
||||||
|
let list = tool_by_name(backend.clone(), "TicketList");
|
||||||
|
let show = tool_by_name(backend.clone(), "TicketShow");
|
||||||
|
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
||||||
|
|
||||||
|
let created = create
|
||||||
|
.execute(
|
||||||
|
&json!({
|
||||||
|
"title": "Tool Created",
|
||||||
|
"slug": "tool-created",
|
||||||
|
"labels": ["ticket", "tool"],
|
||||||
|
"body": "## Background\n\nCreated by tool.\n"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(created.summary.contains("Created ticket"));
|
||||||
|
let created_json: Value = serde_json::from_str(&created.content.unwrap()).unwrap();
|
||||||
|
let id = created_json["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let listed = list
|
||||||
|
.execute(&json!({ "status": "open", "label": "tool" }).to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(listed.summary.contains("Listed 1 ticket"));
|
||||||
|
assert!(listed.content.unwrap().contains("Tool Created"));
|
||||||
|
|
||||||
|
let shown = show
|
||||||
|
.execute(&json!({ "id": id, "event_limit": 10 }).to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(shown.summary.contains("tool-created"));
|
||||||
|
assert!(shown.content.unwrap().contains("Created by tool"));
|
||||||
|
|
||||||
|
let report = doctor.execute(&json!({}).to_string()).await.unwrap();
|
||||||
|
assert!(report.summary.contains("0 error(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ticket_tools_comment_review_status_and_close_are_doctor_clean() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
||||||
|
let comment = tool_by_name(backend.clone(), "TicketComment");
|
||||||
|
let review = tool_by_name(backend.clone(), "TicketReview");
|
||||||
|
let status = tool_by_name(backend.clone(), "TicketStatus");
|
||||||
|
let close = tool_by_name(backend.clone(), "TicketClose");
|
||||||
|
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
||||||
|
|
||||||
|
comment
|
||||||
|
.execute(
|
||||||
|
&json!({
|
||||||
|
"ticket": created.slug,
|
||||||
|
"role": "implementation_report",
|
||||||
|
"body": "Implemented."
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
review
|
||||||
|
.execute(
|
||||||
|
&json!({
|
||||||
|
"ticket": created.id,
|
||||||
|
"result": "approve",
|
||||||
|
"body": "Looks good."
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
status
|
||||||
|
.execute(&json!({ "ticket": created.slug, "status": "pending" }).to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
close
|
||||||
|
.execute(
|
||||||
|
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = doctor.execute(&json!({}).to_string()).await.unwrap();
|
||||||
|
assert!(report.summary.contains("0 error(s)"));
|
||||||
|
let closed = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
|
||||||
|
assert!(closed.resolution.is_some());
|
||||||
|
assert!(
|
||||||
|
closed
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
closed
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.kind == TicketEventKind::Review)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
closed
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.kind == TicketEventKind::StatusChanged)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ticket_show_requires_exactly_one_identifier() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let show = tool_by_name(backend(&temp), "TicketShow");
|
||||||
|
let error = show
|
||||||
|
.execute(&json!({ "id": "a", "slug": "b" }).to_string())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ticket_create_slug_path_traversal_is_sanitized_under_backend_root() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = backend(&temp);
|
||||||
|
let create = tool_by_name(backend.clone(), "TicketCreate");
|
||||||
|
create
|
||||||
|
.execute(&json!({ "title": "Escape", "slug": "../escape" }).to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!temp.path().join("escape").exists());
|
||||||
|
assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_tool_definitions_have_expected_names_and_schemas() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let names = ticket_tools(backend(&temp))
|
||||||
|
.into_iter()
|
||||||
|
.map(|definition| definition().0)
|
||||||
|
.map(|meta| {
|
||||||
|
assert_eq!(meta.input_schema["type"], "object");
|
||||||
|
meta.name
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(names, TICKET_TOOL_NAMES);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn individual_tool_definition_factory_is_callable() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let create = tool(tool_definition::<TicketCreateTool>(
|
||||||
|
"TicketCreate",
|
||||||
|
CREATE_DESCRIPTION,
|
||||||
|
backend(&temp),
|
||||||
|
));
|
||||||
|
let _ = create;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-zf8YS4d/ia/nGTH7MbkWO8ipqjc1ZNnUsnKlS5rH2pQ=";
|
cargoHash = "sha256-UuKaulbFazTojfaCASnLHYMhmDSgX5LQ0ksJRndq+2w=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user