ticket: simplify ticket identity fields

This commit is contained in:
2026-06-09 11:57:15 +09:00
parent 48efcc7dfb
commit 191a875f5a
747 changed files with 1311 additions and 2600 deletions
+253 -365
View File
@@ -1,7 +1,7 @@
//! Ticket domain types and the local `.yoi/tickets/` file backend.
//!
//! The public domain name is **Ticket**. `LocalTicketBackend` preserves the
//! repository's current `.yoi/tickets/{open,pending,closed}/<id>/` layout and the
//! repository's current flat `.yoi/tickets/<ticket-id>/` layout and the
//! event/thread format while exposing typed Rust operations.
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -19,23 +19,7 @@ use thiserror::Error;
pub mod config;
pub mod tool;
const STATUSES: [TicketStatus; 3] = [
TicketStatus::Open,
TicketStatus::Pending,
TicketStatus::Closed,
];
const REQUIRED_FIELDS: [&str; 10] = [
"id",
"slug",
"title",
"status",
"kind",
"priority",
"labels",
"created_at",
"updated_at",
"assignee",
];
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
const ORCHESTRATION_PLAN_ARTIFACT: &str = "orchestration-plan.jsonl";
@@ -184,6 +168,7 @@ pub enum TicketWorkflowState {
Queued,
InProgress,
Done,
Closed,
}
impl TicketWorkflowState {
@@ -194,6 +179,7 @@ impl TicketWorkflowState {
Self::Queued => "queued",
Self::InProgress => "inprogress",
Self::Done => "done",
Self::Closed => "closed",
}
}
@@ -204,13 +190,14 @@ impl TicketWorkflowState {
"queued" => Some(Self::Queued),
"inprogress" => Some(Self::InProgress),
"done" => Some(Self::Done),
"closed" => Some(Self::Closed),
_ => None,
}
}
pub fn default_for_status(status: &ExtensibleTicketStatus) -> Self {
match status {
ExtensibleTicketStatus::Closed => Self::Done,
ExtensibleTicketStatus::Closed => Self::Closed,
_ => Self::Planning,
}
}
@@ -532,17 +519,22 @@ impl NewTicket {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TicketFilter {
pub status: Option<TicketStatus>,
pub state: Option<TicketWorkflowState>,
}
impl TicketFilter {
pub fn all() -> Self {
Self { status: None }
Self { state: None }
}
pub fn state(state: TicketWorkflowState) -> Self {
Self { state: Some(state) }
}
pub fn status(status: TicketStatus) -> Self {
Self {
status: Some(status),
match status {
TicketStatus::Closed => Self::state(TicketWorkflowState::Closed),
TicketStatus::Open | TicketStatus::Pending => Self::all(),
}
}
}
@@ -640,7 +632,6 @@ pub struct NewOrchestrationPlanRecord {
pub struct OrchestrationPlanRecord {
pub id: String,
pub ticket_id: String,
pub ticket_slug: String,
pub kind: OrchestrationPlanKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub related_ticket: Option<String>,
@@ -882,11 +873,11 @@ impl LocalTicketBackend {
}
}
fn status_changed_body(&self, status: TicketStatus) -> String {
fn state_changed_body(&self, state: TicketWorkflowState) -> String {
if is_japanese_record_language(self.record_language()) {
format!("Ticket status を `{}` に変更しました。\n", status.as_str())
format!("Ticket state を `{}` に変更しました。\n", state.as_str())
} else {
format!("Status changed to `{}`.\n", status.as_str())
format!("State changed to `{}`.\n", state.as_str())
}
}
@@ -899,15 +890,14 @@ impl LocalTicketBackend {
}
fn ensure_backend_dirs(&self) -> Result<()> {
for status in STATUSES {
let dir = self.status_dir(status);
fs::create_dir_all(&dir).map_err(|e| io_err(dir, e))?;
}
Ok(())
fs::create_dir_all(&self.root).map_err(|e| io_err(&self.root, e))
}
fn status_dir(&self, status: TicketStatus) -> PathBuf {
self.root.join(status.as_str())
fn ticket_dir(&self, id: &str) -> Result<PathBuf> {
ensure_safe_component(id)?;
let dir = self.root.join(id);
ensure_child_of(&self.root, &dir)?;
Ok(dir)
}
fn acquire_lock(&self) -> Result<BackendLock> {
@@ -928,60 +918,53 @@ impl LocalTicketBackend {
}
}
fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result<Vec<(TicketStatus, PathBuf)>> {
fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result<Vec<PathBuf>> {
let mut dirs = Vec::new();
for status in STATUSES {
if let Some(filter_status) = filter.status {
if status != filter_status {
if !self.root.exists() {
return Ok(dirs);
}
let entries = fs::read_dir(&self.root).map_err(|e| io_err(&self.root, e))?;
for entry in entries {
let entry = entry.map_err(|e| io_err(&self.root, e))?;
let path = entry.path();
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if !path.is_dir() || name.starts_with('.') {
continue;
}
let item = path.join("item.md");
if !item.is_file() {
continue;
}
if let Some(state) = filter.state {
let parsed = read_item_file(&item)?;
let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?;
if meta.workflow_state != state {
continue;
}
}
let status_dir = self.status_dir(status);
if !status_dir.exists() {
continue;
}
let entries = fs::read_dir(&status_dir).map_err(|e| io_err(&status_dir, e))?;
for entry in entries {
let entry = entry.map_err(|e| io_err(&status_dir, e))?;
let path = entry.path();
if path.is_dir() {
dirs.push((status, path));
}
}
dirs.push(path);
}
dirs.sort_by(|(_, a), (_, b)| a.cmp(b));
dirs.sort();
Ok(dirs)
}
fn find_ticket_dir(&self, query: &TicketIdOrSlug) -> Result<PathBuf> {
let query = query.as_query();
let mut matches = Vec::new();
for (_, dir) in self.iter_ticket_dirs(TicketFilter::all())? {
let item = dir.join("item.md");
if !item.exists() {
continue;
}
let parsed = read_item_file(&item)?;
let id = parsed.frontmatter.get("id").map(String::as_str);
let slug = parsed.frontmatter.get("slug").map(String::as_str);
if id == Some(query) || slug == Some(query) {
matches.push(dir);
}
}
match matches.len() {
0 => Err(TicketError::NotFound(query.to_string())),
1 => Ok(matches.remove(0)),
_ => Err(TicketError::Ambiguous {
query: query.to_string(),
matches,
}),
let dir = self.ticket_dir(query)?;
if dir.join("item.md").is_file() {
Ok(dir)
} else {
Err(TicketError::NotFound(query.to_string()))
}
}
fn ticket_from_dir(&self, dir: &Path) -> Result<Ticket> {
let item_path = dir.join("item.md");
let parsed = read_item_file(&item_path)?;
let meta = ticket_meta(parsed.frontmatter.clone());
let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?;
let document = TicketDocument {
body: MarkdownText::new(parsed.body),
raw_frontmatter: parsed.frontmatter.raw,
@@ -1010,9 +993,10 @@ impl LocalTicketBackend {
})
}
fn ticket_workflow_state_from_item(&self, item: &Path) -> Result<TicketWorkflowState> {
let parsed = read_item_file(item)?;
let meta = ticket_meta(parsed.frontmatter);
fn ticket_workflow_state_from_dir(&self, dir: &Path) -> Result<TicketWorkflowState> {
let item = dir.join("item.md");
let parsed = read_item_file(&item)?;
let meta = ticket_meta_for_dir(dir, parsed.frontmatter)?;
Ok(meta.workflow_state)
}
@@ -1035,16 +1019,16 @@ impl LocalTicketBackend {
)));
}
let item = dir.join("item.md");
let current = self.ticket_workflow_state_from_item(&item)?;
let current = self.ticket_workflow_state_from_dir(dir)?;
if current != expected_from {
return Err(TicketError::Conflict(format!(
"workflow_state changed concurrently: expected `{}`, found `{}`",
"state changed concurrently: expected `{}`, found `{}`",
expected_from.as_str(),
current.as_str()
)));
}
self.append_state_changed_event(dir, &change, Some("workflow_state"))?;
let mut updates = vec![("workflow_state", to.as_str())];
self.append_state_changed_event(dir, &change, Some("state"))?;
let mut updates = vec![("state", to.as_str())];
updates.extend_from_slice(extra_updates);
self.set_frontmatter_fields(&item, &updates)
}
@@ -1143,7 +1127,7 @@ impl LocalTicketBackend {
dir: &Path,
) -> Result<Vec<OrchestrationPlanRecord>> {
let item = dir.join("item.md");
let meta = ticket_meta(read_item_file(&item)?.frontmatter);
let meta = ticket_meta_for_dir(dir, read_item_file(&item)?.frontmatter)?;
let path = self.orchestration_plan_path(dir);
read_orchestration_plan_artifact(&path, Some(&meta))
}
@@ -1152,13 +1136,13 @@ impl LocalTicketBackend {
impl TicketBackend for LocalTicketBackend {
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>> {
let mut tickets = Vec::new();
for (_, dir) in self.iter_ticket_dirs(filter)? {
for dir in self.iter_ticket_dirs(filter)? {
let item = dir.join("item.md");
if !item.exists() {
continue;
}
let parsed = read_item_file(&item)?;
let meta = ticket_meta(parsed.frontmatter);
let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?;
tickets.push(TicketSummary {
id: meta.id,
slug: meta.slug,
@@ -1193,29 +1177,21 @@ impl TicketBackend for LocalTicketBackend {
"ticket title must not be empty".to_string(),
));
}
let slug = slugify(input.slug.as_deref().unwrap_or(&input.title));
let slug = if slug.is_empty() {
"item".to_string()
} else {
slug
};
ensure_safe_component(&slug)?;
let stamp = compact_now_utc();
let mut id = format!("{stamp}-{slug}");
ensure_safe_component(&id)?;
let mut dir = self.status_dir(TicketStatus::Open).join(&id);
if dir.exists() {
id = format!("{id}-{}", std::process::id());
ensure_safe_component(&id)?;
dir = self.status_dir(TicketStatus::Open).join(&id);
}
if dir.exists() {
return Err(TicketError::Conflict(format!(
"target already exists: {}",
dir.display()
)));
}
ensure_child_of(&self.root, &dir)?;
let mut counter = 1_u32;
let (id, dir) = loop {
let candidate = format!("{stamp}-{counter:03}");
let dir = self.ticket_dir(&candidate)?;
if !dir.exists() {
break (candidate, dir);
}
counter += 1;
if counter > 999 {
return Err(TicketError::Conflict(format!(
"too many ticket id collisions for timestamp {stamp}"
)));
}
};
let created = now_utc();
let author = input
.author
@@ -1229,24 +1205,12 @@ impl TicketBackend for LocalTicketBackend {
fs::create_dir_all(dir.join("artifacts")).map_err(|e| io_err(&dir, e))?;
atomic_write(&dir.join("artifacts/.gitkeep"), b"")?;
let mut fields = Vec::new();
fields.push(("id".to_string(), format_yaml_string_scalar(&id)));
fields.push(("slug".to_string(), format_yaml_string_scalar(&slug)));
fields.push((
"title".to_string(),
format_yaml_string_scalar(input.title.as_str()),
));
fields.push(("status".to_string(), format_yaml_string_scalar("open")));
fields.push((
"kind".to_string(),
format_yaml_string_scalar(input.kind.as_str()),
));
fields.push((
"priority".to_string(),
format_yaml_string_scalar(input.priority.as_str()),
));
fields.push(("labels".to_string(), labels_yaml(&input.labels)));
fields.push((
"workflow_state".to_string(),
"state".to_string(),
format_yaml_string_scalar(
input
.workflow_state
@@ -1313,8 +1277,8 @@ impl TicketBackend for LocalTicketBackend {
);
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
Ok(TicketRef {
id,
slug,
id: id.clone(),
slug: id,
status: TicketStatus::Open,
})
}
@@ -1353,9 +1317,9 @@ impl TicketBackend for LocalTicketBackend {
change: TicketStateChange,
) -> Result<()> {
validate_state_field_name(field)?;
if field == "workflow_state" {
if field == "state" || field == "workflow_state" || field == "status" {
return Err(TicketError::Conflict(
"workflow_state transitions must use dedicated workflow APIs".to_string(),
"ticket lifecycle state transitions must use dedicated lifecycle APIs".to_string(),
));
}
let _lock = self.acquire_lock()?;
@@ -1429,10 +1393,10 @@ impl TicketBackend for LocalTicketBackend {
}
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
let current = self.ticket_workflow_state_from_item(&dir.join("item.md"))?;
let current = self.ticket_workflow_state_from_dir(&dir)?;
if current != from {
return Err(TicketError::Conflict(format!(
"workflow_state changed concurrently: expected `{}`, found `{}`",
"state changed concurrently: expected `{}`, found `{}`",
from.as_str(),
current.as_str()
)));
@@ -1478,91 +1442,55 @@ impl TicketBackend for LocalTicketBackend {
}
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()> {
let target_state = match status {
TicketStatus::Closed => TicketWorkflowState::Closed,
TicketStatus::Open | TicketStatus::Pending => TicketWorkflowState::Planning,
};
let _lock = self.acquire_lock()?;
self.ensure_backend_dirs()?;
let old_dir = self.find_ticket_dir(&id)?;
let item = old_dir.join("item.md");
let parsed = read_item_file(&item)?;
let ticket_id =
parsed.frontmatter.get("id").cloned().ok_or_else(|| {
TicketError::Conflict(format!("missing id in {}", item.display()))
})?;
ensure_safe_component(&ticket_id)?;
let new_dir = self.status_dir(status).join(&ticket_id);
ensure_child_of(&self.root, &new_dir)?;
if old_dir != new_dir {
if new_dir.exists() {
return Err(TicketError::Conflict(format!(
"target already exists: {}",
new_dir.display()
)));
}
fs::rename(&old_dir, &new_dir).map_err(|e| io_err(&new_dir, e))?;
}
self.set_frontmatter_fields(&new_dir.join("item.md"), &[("status", status.as_str())])?;
let author = default_author();
let body = MarkdownText::new(self.status_changed_body(status));
self.append_thread_event(
&new_dir,
"status_changed",
self.generated_heading("Status changed", "ステータス変更"),
&author,
Some(status.as_str()),
&[],
&body,
let dir = self.find_ticket_dir(&id)?;
let current_state = self.ticket_workflow_state_from_dir(&dir)?;
let at = now_utc();
let change = TicketStateChange::new(
current_state.as_str(),
target_state.as_str(),
"state_changed",
self.state_changed_body(target_state),
);
self.append_state_changed_event(&dir, &change, Some("state"))?;
self.set_frontmatter_fields(
&dir.join("item.md"),
&[(("state"), target_state.as_str()), ("updated_at", &at)],
)
}
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
let _lock = self.acquire_lock()?;
self.ensure_backend_dirs()?;
let old_dir = self.find_ticket_dir(&id)?;
let item = old_dir.join("item.md");
let parsed = read_item_file(&item)?;
let ticket_id =
parsed.frontmatter.get("id").cloned().ok_or_else(|| {
TicketError::Conflict(format!("missing id in {}", item.display()))
})?;
ensure_safe_component(&ticket_id)?;
let closed_dir = self.status_dir(TicketStatus::Closed).join(&ticket_id);
ensure_child_of(&self.root, &closed_dir)?;
if old_dir != closed_dir {
if closed_dir.exists() {
return Err(TicketError::Conflict(format!(
"target already exists: {}",
closed_dir.display()
)));
}
fs::rename(&old_dir, &closed_dir).map_err(|e| io_err(&closed_dir, e))?;
}
let dir = self.find_ticket_dir(&id)?;
let at = now_utc();
let current_workflow_state =
self.ticket_workflow_state_from_item(&closed_dir.join("item.md"))?;
if current_workflow_state != TicketWorkflowState::Done {
let current_workflow_state = self.ticket_workflow_state_from_dir(&dir)?;
if current_workflow_state != TicketWorkflowState::Closed {
let mut change = TicketStateChange::new(
current_workflow_state.as_str(),
TicketWorkflowState::Done.as_str(),
TicketWorkflowState::Closed.as_str(),
"closed",
self.closed_workflow_state_body(),
);
change.author = Some(default_author());
self.append_state_changed_event(&closed_dir, &change, Some("workflow_state"))?;
self.append_state_changed_event(&dir, &change, Some("state"))?;
}
self.set_frontmatter_fields(
&closed_dir.join("item.md"),
&dir.join("item.md"),
&[
("status", "closed"),
("workflow_state", TicketWorkflowState::Done.as_str()),
("state", TicketWorkflowState::Closed.as_str()),
("updated_at", &at),
],
)?;
atomic_write(
&closed_dir.join("resolution.md"),
resolution.as_str().as_bytes(),
)?;
atomic_write(&dir.join("resolution.md"), resolution.as_str().as_bytes())?;
let author = default_author();
self.append_thread_event(
&closed_dir,
&dir,
"close",
self.generated_heading("Closed", "完了"),
&author,
@@ -1582,7 +1510,7 @@ impl TicketBackend for LocalTicketBackend {
self.ensure_backend_dirs()?;
let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md");
let meta = ticket_meta(read_item_file(&item)?.frontmatter);
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
let artifacts = dir.join("artifacts");
fs::create_dir_all(&artifacts).map_err(|e| io_err(&artifacts, e))?;
let path = self.orchestration_plan_path(&dir);
@@ -1600,7 +1528,6 @@ impl TicketBackend for LocalTicketBackend {
let output = OrchestrationPlanRecord {
id: format!("orch-plan-{}-{}", compact_now_utc(), line_count + 1),
ticket_id: meta.id.clone(),
ticket_slug: meta.slug.clone(),
kind: record.kind,
related_ticket: record.related_ticket.map(trim_owned),
note: record.note.map(trim_owned),
@@ -1635,18 +1562,8 @@ impl TicketBackend for LocalTicketBackend {
let dir = self.find_ticket_dir(&ticket)?;
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
} else {
for status in STATUSES {
let status_dir = self.status_dir(status);
if !status_dir.is_dir() {
continue;
}
for entry in fs::read_dir(&status_dir).map_err(|e| io_err(&status_dir, e))? {
let entry = entry.map_err(|e| io_err(&status_dir, e))?;
let dir = entry.path();
if dir.is_dir() {
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
}
}
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
}
}
if let Some(kind) = kind {
@@ -1658,150 +1575,108 @@ impl TicketBackend for LocalTicketBackend {
fn doctor(&self) -> Result<TicketDoctorReport> {
let mut report = TicketDoctorReport::default();
for status in STATUSES {
let dir = self.status_dir(status);
if !dir.is_dir() {
report.push_error(format!("missing directory: {}", dir.display()), Some(dir));
}
}
let mut ids: HashMap<String, PathBuf> = HashMap::new();
let mut duplicate_ids: BTreeSet<String> = BTreeSet::new();
let mut slugs: HashMap<String, PathBuf> = HashMap::new();
let mut duplicate_slugs: BTreeSet<String> = BTreeSet::new();
for status in STATUSES {
let status_dir = self.status_dir(status);
if !status_dir.is_dir() {
continue;
for legacy_bucket in ["open", "pending", "closed"] {
let legacy_dir = self.root.join(legacy_bucket);
if legacy_dir.is_dir() {
report.push_error(
format!("legacy ticket bucket remains: {}", legacy_dir.display()),
Some(legacy_dir),
);
}
for entry in fs::read_dir(&status_dir).map_err(|e| io_err(&status_dir, e))? {
let entry = entry.map_err(|e| io_err(&status_dir, e))?;
let dir = entry.path();
if !dir.is_dir() {
}
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
let ticket_id = match ticket_id_from_dir(&dir) {
Ok(id) => id,
Err(err) => {
report.push_error(err.to_string(), Some(dir.clone()));
continue;
}
let item = dir.join("item.md");
let thread = dir.join("thread.md");
let artifacts = dir.join("artifacts");
if !item.is_file() {
report.push_error(
format!("missing item.md: {}", dir.display()),
Some(dir.clone()),
);
};
if ids.insert(ticket_id.clone(), dir.clone()).is_some() {
duplicate_ids.insert(ticket_id.clone());
}
let item = dir.join("item.md");
let thread = dir.join("thread.md");
let artifacts = dir.join("artifacts");
if !thread.is_file() {
report.push_error(
format!("missing thread.md: {}", dir.display()),
Some(thread.clone()),
);
}
if !artifacts.is_dir() {
report.push_error(
format!("missing artifacts/: {}", dir.display()),
Some(artifacts.clone()),
);
}
let parsed = match read_item_file(&item) {
Ok(parsed) => parsed,
Err(TicketError::Parse { message, .. }) => {
report.push_error(message, Some(item.clone()));
continue;
}
if !thread.is_file() {
report.push_error(
format!("missing thread.md: {}", dir.display()),
Some(thread.clone()),
);
}
if !artifacts.is_dir() {
report.push_error(
format!("missing artifacts/: {}", dir.display()),
Some(artifacts.clone()),
);
}
let parsed = match read_item_file(&item) {
Ok(parsed) => parsed,
Err(TicketError::Parse { message, .. }) => {
report.push_error(message, Some(item.clone()));
continue;
}
Err(e) => return Err(e),
};
for field in REQUIRED_FIELDS {
if parsed
.frontmatter
.get(field)
.is_none_or(|value| value.is_empty())
{
report.push_error(
format!("missing required field '{field}': {}", item.display()),
Some(item.clone()),
);
}
}
if let Some(id) = parsed.frontmatter.get("id") {
if ids.insert(id.clone(), item.clone()).is_some() {
duplicate_ids.insert(id.clone());
}
if dir.file_name().and_then(|name| name.to_str()) != Some(id.as_str()) {
report.push_error(
format!("directory id mismatch: {} has id {id}", dir.display()),
Some(dir.clone()),
);
}
}
if let Some(slug) = parsed.frontmatter.get("slug") {
if slugs.insert(slug.clone(), item.clone()).is_some() {
duplicate_slugs.insert(slug.clone());
}
}
let fm_status = parsed
Err(e) => return Err(e),
};
for field in REQUIRED_FIELDS {
if parsed
.frontmatter
.get("status")
.map(String::as_str)
.unwrap_or("");
if fm_status != status.as_str() {
.get(field)
.is_none_or(|value| value.is_empty())
{
report.push_error(
format!(
"status mismatch: {} has '{fm_status}' under '{}'",
item.display(),
status.as_str()
),
format!("missing required field '{field}': {}", item.display()),
Some(item.clone()),
);
}
match parsed.frontmatter.get("workflow_state").map(String::as_str) {
Some(value) if TicketWorkflowState::parse(value).is_none() => report
.push_error(
format!("invalid workflow_state '{value}': {}", item.display()),
Some(item.clone()),
),
_ => {}
}
if status == TicketStatus::Closed
&& parsed
.frontmatter
.get("workflow_state")
.is_none_or(|value| value != TicketWorkflowState::Done.as_str())
{
report.push_warning(
}
for obsolete in ["id", "slug", "status", "workflow_state", "kind", "labels"] {
if parsed.frontmatter.get(obsolete).is_some() {
report.push_error(
format!(
"closed ticket should have workflow_state: done: {}",
"obsolete current frontmatter field '{obsolete}': {}",
item.display()
),
Some(item.clone()),
);
}
if status == TicketStatus::Closed && !dir.join("resolution.md").is_file() {
report.push_warning(
format!("closed ticket missing resolution.md: {}", dir.display()),
Some(dir.join("resolution.md")),
);
}
if thread.exists() {
doctor_thread_events(&thread, &mut report)?;
}
if artifacts.exists() {
doctor_artifacts(&artifacts, &mut report)?;
let meta = ticket_meta(parsed.frontmatter.clone());
doctor_orchestration_plan_artifact(
&artifacts.join(ORCHESTRATION_PLAN_ARTIFACT),
&meta,
&mut report,
)?;
}
}
match parsed.frontmatter.get("state").map(String::as_str) {
Some(value) if TicketWorkflowState::parse(value).is_none() => report.push_error(
format!("invalid state '{value}': {}", item.display()),
Some(item.clone()),
),
_ => {}
}
if parsed.frontmatter.get("state").map(String::as_str) == Some("closed")
&& !dir.join("resolution.md").is_file()
{
report.push_warning(
format!("closed ticket missing resolution.md: {}", dir.display()),
Some(dir.join("resolution.md")),
);
}
if thread.exists() {
doctor_thread_events(&thread, &mut report)?;
}
if artifacts.exists() {
doctor_artifacts(&artifacts, &mut report)?;
let meta = ticket_meta_for_dir(&dir, parsed.frontmatter.clone())?;
doctor_orchestration_plan_artifact(
&artifacts.join(ORCHESTRATION_PLAN_ARTIFACT),
&meta,
&mut report,
)?;
}
}
for duplicate in duplicate_ids {
report.push_error(format!("duplicate id: {duplicate}"), None);
}
for duplicate in duplicate_slugs {
report.push_error(format!("duplicate slug: {duplicate}"), None);
}
let todo = self
.root
@@ -1854,6 +1729,7 @@ struct ParsedItem {
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
struct TicketItemFrontmatter {
id: Option<String>,
slug: Option<String>,
@@ -1870,6 +1746,8 @@ struct TicketItemFrontmatter {
action_required: Option<String>,
workflow_state: Option<TicketWorkflowState>,
workflow_state_explicit: bool,
state: Option<TicketWorkflowState>,
state_explicit: bool,
attention_required: Option<String>,
queued_by: Option<String>,
queued_at: Option<String>,
@@ -1948,7 +1826,15 @@ fn parse_ticket_frontmatter(content: &str) -> std::result::Result<TicketItemFron
let workflow_state_value = yaml_string(&mapping, "workflow_state")?;
let workflow_state = match workflow_state_value.as_deref() {
Some(value) => Some(TicketWorkflowState::parse(value).ok_or_else(|| {
format!("invalid workflow_state '{value}': expected planning, ready, queued, inprogress, or done")
format!("invalid workflow_state '{value}': expected planning, ready, queued, inprogress, done, or closed")
})?),
None => None,
};
let state_explicit = mapping.contains_key(YamlValue::String("state".into()));
let state_value = yaml_string(&mapping, "state")?;
let state = match state_value.as_deref() {
Some(value) => Some(TicketWorkflowState::parse(value).ok_or_else(|| {
format!("invalid state '{value}': expected planning, ready, queued, inprogress, done, or closed")
})?),
None => None,
};
@@ -1969,6 +1855,8 @@ fn parse_ticket_frontmatter(content: &str) -> std::result::Result<TicketItemFron
action_required: yaml_string(&mapping, "action_required")?,
workflow_state,
workflow_state_explicit,
state,
state_explicit,
attention_required: yaml_string(&mapping, "attention_required")?,
queued_by: yaml_string(&mapping, "queued_by")?,
queued_at: yaml_string(&mapping, "queued_at")?,
@@ -2050,23 +1938,45 @@ fn yaml_kind(value: &YamlValue) -> &'static str {
}
}
fn ticket_meta(frontmatter: TicketItemFrontmatter) -> TicketMeta {
let status = frontmatter
.status
.as_deref()
.map(ExtensibleTicketStatus::from)
.unwrap_or_else(|| ExtensibleTicketStatus::Other(String::new()));
fn ticket_id_from_dir(dir: &Path) -> Result<String> {
let Some(name) = dir.file_name().and_then(|name| name.to_str()) else {
return Err(TicketError::Conflict(format!(
"ticket directory has no UTF-8 id: {}",
dir.display()
)));
};
ensure_safe_component(name)?;
Ok(name.to_string())
}
fn ticket_meta_for_dir(dir: &Path, frontmatter: TicketItemFrontmatter) -> Result<TicketMeta> {
Ok(ticket_meta(frontmatter, ticket_id_from_dir(dir)?))
}
fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta {
let workflow_state = frontmatter
.workflow_state
.unwrap_or_else(|| TicketWorkflowState::default_for_status(&status));
.state
.or(frontmatter.workflow_state)
.or_else(|| {
frontmatter
.status
.as_deref()
.map(ExtensibleTicketStatus::from)
.map(|status| TicketWorkflowState::default_for_status(&status))
})
.unwrap_or(TicketWorkflowState::Planning);
let status = match workflow_state {
TicketWorkflowState::Closed => ExtensibleTicketStatus::Closed,
_ => ExtensibleTicketStatus::Open,
};
TicketMeta {
id: frontmatter.id.unwrap_or_default(),
slug: frontmatter.slug.unwrap_or_default(),
id: id.clone(),
slug: id,
title: frontmatter.title.unwrap_or_default(),
status,
kind: frontmatter.kind.unwrap_or_default(),
kind: String::new(),
priority: frontmatter.priority.unwrap_or_default(),
labels: frontmatter.labels,
labels: Vec::new(),
created_at: frontmatter.created_at,
updated_at: frontmatter.updated_at,
assignee: frontmatter.assignee,
@@ -2074,7 +1984,7 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter) -> TicketMeta {
risk_flags: frontmatter.risk_flags,
action_required: frontmatter.action_required,
workflow_state,
workflow_state_explicit: frontmatter.workflow_state_explicit,
workflow_state_explicit: frontmatter.state_explicit,
attention_required: frontmatter.attention_required,
queued_by: frontmatter.queued_by,
queued_at: frontmatter.queued_at,
@@ -2232,16 +2142,6 @@ fn validate_orchestration_plan_record(
Some(&record.ticket_id),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_required_text(
"ticket_slug",
&record.ticket_slug,
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_optional_single_line(
"ticket_slug",
Some(&record.ticket_slug),
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
)?;
validate_plan_required_text("author", &record.author, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
validate_plan_optional_single_line(
"author",
@@ -2259,10 +2159,10 @@ fn validate_orchestration_plan_record(
};
validate_new_orchestration_plan_record(&new_record)?;
if let Some(meta) = meta {
if record.ticket_id != meta.id || record.ticket_slug != meta.slug {
if record.ticket_id != meta.id {
return Err(TicketError::Conflict(format!(
"orchestration plan record {} targets {}/{} but artifact belongs to {}/{}",
record.id, record.ticket_id, record.ticket_slug, meta.id, meta.slug
"orchestration plan record {} targets {} but artifact belongs to {}",
record.id, record.ticket_id, meta.id
)));
}
}
@@ -2857,21 +2757,6 @@ fn ensure_safe_component(value: &str) -> Result<()> {
}
}
fn slugify(value: &str) -> String {
let mut out = String::new();
let mut previous_dash = false;
for ch in value.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
out.push(ch);
previous_dash = false;
} else if !previous_dash {
out.push('-');
previous_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn now_utc() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
@@ -2964,7 +2849,7 @@ queued_at: 2026-06-05T00:01:00Z
## Body
"#;
let parsed = parse_item(item).unwrap();
let meta = ticket_meta(parsed.frontmatter);
let meta = ticket_meta(parsed.frontmatter, "20260609-000000-001".to_string());
assert_eq!(meta.id, "20260605-000000-example");
assert_eq!(meta.labels, vec!["ticket", "backend"]);
assert_eq!(meta.readiness.as_deref(), Some("implementation-ready"));
@@ -2992,7 +2877,7 @@ workflow_state: planning
"#,
)
.unwrap();
let meta = ticket_meta(frontmatter);
let meta = ticket_meta(frontmatter, "20260609-000000-001".to_string());
assert_eq!(meta.labels, vec!["ticket", "backend"]);
assert_eq!(meta.risk_flags, vec!["low", "local"]);
assert_eq!(meta.assignee, None);
@@ -3324,13 +3209,16 @@ workflow_state: planning
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let missing_meta = ticket_meta(
parse_ticket_frontmatter("status: open").expect("missing workflow state parses"),
parse_ticket_frontmatter("state: planning").expect("missing state parses"),
"20260609-000000-001".to_string(),
);
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Planning);
assert!(!missing_meta.workflow_state_explicit);
let closed_meta =
ticket_meta(parse_ticket_frontmatter("status: closed").expect("closed default parses"));
let closed_meta = ticket_meta(
parse_ticket_frontmatter("state: closed").expect("closed state parses"),
"20260609-000000-002".to_string(),
);
assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Done);
assert!(!closed_meta.workflow_state_explicit);
+119 -181
View File
@@ -12,11 +12,11 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
AcceptedOrchestrationPlan, ExtensibleTicketStatus, LocalTicketBackend, MarkdownText,
NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket,
TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError,
TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview,
TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState,
AcceptedOrchestrationPlan, LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord,
NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend,
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind,
TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange,
TicketStatus, TicketSummary, TicketWorkflowState,
};
const DEFAULT_LIST_LIMIT: usize = 100;
@@ -67,9 +67,9 @@ const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed T
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 \
status (`open`, `pending`, `closed`, or `all`) and optionally kind/priority/label. Output is a \
state (`planning`, `ready`, `queued`, `inprogress`, `done`, `closed`, or `all`). 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 \
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.";
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket thread event. `role` must be `comment`, \
@@ -79,24 +79,23 @@ const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
for `workflow_state`, and transitions workflow_state to `ready`.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `workflow_state` through the typed \
Ticket backend with a bounded `state_changed` event. This does not move local open/pending/closed \
status; use `TicketStatus` or `TicketClose` for local status changes. Treat `queued -> inprogress` \
for `state`, and transitions state to `ready`.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statuses through the typed \
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statees through the typed \
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
by `yoi ticket 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 \
backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
a close event.";
const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \
for ordering, dependency, conflict, waiting/capacity, or accepted-plan decisions. Records are durable \
Ticket artifacts and do not move workflow_state, reorder queues, or start work.";
Ticket artifacts and do not move state, reorder queues, or start work.";
const ORCHESTRATION_PLAN_QUERY_DESCRIPTION: &str = "Query durable Ticket orchestration plan records by \
Ticket id/slug and/or relation kind. This is read-only planning context; Orchestrator must still make \
explicit workflow_state decisions.";
Ticket id and/or relation kind. This is read-only planning context; Orchestrator must still make \
explicit state decisions.";
const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
diagnostics through the typed backend without shelling out to external commands.";
@@ -104,18 +103,6 @@ diagnostics through the typed backend without shelling out to external commands.
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>,
@@ -134,9 +121,9 @@ struct TicketCreateParams {
/// Optional action-required frontmatter value.
#[serde(default)]
action_required: Option<String>,
/// Optional workflow_state frontmatter value. Defaults to `planning`.
/// Optional state frontmatter value. Defaults to `planning`.
#[serde(default)]
workflow_state: Option<TicketWorkflowStateParam>,
state: Option<TicketWorkflowStateParam>,
/// Optional attention_required overlay frontmatter value.
#[serde(default)]
attention_required: Option<String>,
@@ -156,6 +143,7 @@ enum TicketWorkflowStateParam {
Queued,
Inprogress,
Done,
Closed,
}
impl TicketWorkflowStateParam {
@@ -166,47 +154,71 @@ impl TicketWorkflowStateParam {
Self::Queued => TicketWorkflowState::Queued,
Self::Inprogress => TicketWorkflowState::InProgress,
Self::Done => TicketWorkflowState::Done,
Self::Closed => TicketWorkflowState::Closed,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketListStatusParam {
Open,
Pending,
enum TicketListStateParam {
Planning,
Ready,
Queued,
Inprogress,
Done,
Closed,
All,
}
impl TicketListStateParam {
fn as_filter(self) -> (crate::TicketFilter, &'static str) {
match self {
Self::Planning => (
crate::TicketFilter::state(TicketWorkflowState::Planning),
"planning",
),
Self::Ready => (
crate::TicketFilter::state(TicketWorkflowState::Ready),
"ready",
),
Self::Queued => (
crate::TicketFilter::state(TicketWorkflowState::Queued),
"queued",
),
Self::Inprogress => (
crate::TicketFilter::state(TicketWorkflowState::InProgress),
"inprogress",
),
Self::Done => (
crate::TicketFilter::state(TicketWorkflowState::Done),
"done",
),
Self::Closed => (
crate::TicketFilter::state(TicketWorkflowState::Closed),
"closed",
),
Self::All => (crate::TicketFilter::all(), "all"),
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketListParams {
/// Status filter. Defaults to `open`; use `all` to include closed and pending Tickets.
/// State filter. Defaults to all Tickets.
#[serde(default)]
status: Option<TicketListStatusParam>,
state: Option<TicketListStateParam>,
/// 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.
/// Ticket id. Exactly one of `id` 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.
/// Exact ticket id query. Exactly one of `id` or `query` must be provided.
#[serde(default)]
query: Option<String>,
/// Maximum number of most-recent thread events to return. Defaults to 20, max 100.
@@ -231,7 +243,7 @@ enum TicketCommentRoleParam {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCommentParams {
/// Ticket id or slug.
/// Ticket id.
ticket: String,
/// Thread event role: `comment`, `plan`, `decision`, or `implementation_report`.
role: TicketCommentRoleParam,
@@ -251,7 +263,7 @@ enum TicketReviewResultParam {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketReviewParams {
/// Ticket id or slug.
/// Ticket id.
ticket: String,
/// Review result: `approve` or `request_changes`.
result: TicketReviewResultParam,
@@ -271,15 +283,15 @@ enum TicketStatusParam {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketStatusParams {
/// Ticket id or slug.
/// Ticket id.
ticket: String,
/// New status. Use `TicketClose` for `closed`.
status: TicketStatusParam,
/// New state. Use `TicketClose` for `closed`.
state: TicketStatusParam,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams {
/// Ticket id or slug.
/// Ticket id.
ticket: String,
/// Concise bounded intake summary to append as a typed intake_summary event.
intake_summary: String,
@@ -296,11 +308,11 @@ struct TicketIntakeReadyParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketWorkflowStateParams {
/// Ticket id or slug.
/// Ticket id.
ticket: String,
/// Expected current workflow_state. The backend rejects stale transitions.
/// Expected current state. The backend rejects stale transitions.
from: TicketWorkflowStateParam,
/// Target workflow_state.
/// Target state.
to: TicketWorkflowStateParam,
/// Reason attached to the typed state_changed event.
reason: String,
@@ -313,7 +325,7 @@ struct TicketWorkflowStateParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCloseParams {
/// Ticket id or slug.
/// Ticket id.
ticket: String,
/// Markdown resolution written to resolution.md and thread.md.
resolution: String,
@@ -364,11 +376,11 @@ struct AcceptedOrchestrationPlanParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketOrchestrationPlanRecordParams {
/// Ticket id or slug that owns this orchestration plan record.
/// Ticket id that owns this orchestration plan record.
ticket: String,
/// Record kind: before/after, blocked_by/blocks, conflicts_with/do_not_parallelize, waiting_capacity_note, or accepted_plan.
kind: OrchestrationPlanKindParam,
/// Related Ticket id/slug for ordering, dependency, and conflict records.
/// Related Ticket id for ordering, dependency, and conflict records.
#[serde(default)]
related_ticket: Option<String>,
/// Optional bounded rationale/note. Required for waiting_capacity_note.
@@ -384,7 +396,7 @@ struct TicketOrchestrationPlanRecordParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketOrchestrationPlanQueryParams {
/// Optional Ticket id or slug to query. Omit to query across the backend root.
/// Optional Ticket id to query. Omit to query across the backend root.
#[serde(default)]
ticket: Option<String>,
/// Optional relation kind filter.
@@ -413,13 +425,12 @@ struct TicketDoctorParams {
#[derive(Debug, Serialize)]
struct TicketRefOutput {
id: String,
slug: String,
status: String,
state: String,
}
#[derive(Debug, Serialize)]
struct TicketListOutput {
status_filter: String,
state_filter: String,
count: usize,
returned: usize,
truncated: bool,
@@ -501,14 +512,6 @@ 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);
}
@@ -517,9 +520,7 @@ impl Tool for TicketCreateTool {
input.readiness = params.readiness;
input.risk_flags = params.risk_flags;
input.action_required = params.action_required;
input.workflow_state = params
.workflow_state
.map(TicketWorkflowStateParam::into_state);
input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state);
input.attention_required = params.attention_required;
input.queued_by = params.queued_by;
input.queued_at = params.queued_at;
@@ -529,13 +530,11 @@ impl Tool for TicketCreateTool {
.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),
format!("Created ticket {}", created.id),
json!(TicketRefOutput {
id: created.id,
state: "planning".to_string(),
}),
))
}
}
@@ -544,35 +543,13 @@ impl Tool for TicketCreateTool {
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 state = params.state.unwrap_or(TicketListStateParam::All);
let (filter, state_filter) = state.as_filter();
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
let mut tickets = self
let 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()
@@ -580,7 +557,7 @@ impl Tool for TicketListTool {
.map(ticket_summary_json)
.collect();
let output = TicketListOutput {
status_filter: status_filter.to_string(),
state_filter: state_filter.to_string(),
count,
returned: returned_tickets.len(),
truncated: count > returned_tickets.len(),
@@ -588,7 +565,7 @@ impl Tool for TicketListTool {
};
Ok(json_output(
format!(
"Listed {} ticket(s) for status {status_filter}{}",
"Listed {} ticket(s) for state {state_filter}{}",
output.returned,
if output.truncated { " (truncated)" } else { "" }
),
@@ -601,7 +578,7 @@ impl Tool for TicketListTool {
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 query = id_or_query(params.id, params.query)?;
let event_limit = bounded(params.event_limit, DEFAULT_EVENT_LIMIT, MAX_EVENT_LIMIT);
let artifact_limit = bounded(
params.artifact_limit,
@@ -618,10 +595,9 @@ impl Tool for TicketShowTool {
.show(query)
.map_err(|error| backend_error("TicketShow", error))?;
let summary = format!(
"Ticket {} ({}) status {}",
"Ticket {} state {}",
ticket.meta.id,
ticket.meta.slug,
status_as_str(&ticket.meta.status)
ticket.meta.workflow_state.as_str()
);
Ok(json_output(
summary,
@@ -706,8 +682,8 @@ impl Tool for TicketIntakeReadyTool {
)
.map_err(|error| backend_error("TicketIntakeReady", error))?;
Ok(json_output(
format!("Marked ticket {} workflow_state ready", params.ticket),
json!({ "ticket": params.ticket, "workflow_state": "ready", "ok": true }),
format!("Marked ticket {} state ready", params.ticket),
json!({ "ticket": params.ticket, "state": "ready", "ok": true }),
))
}
}
@@ -720,7 +696,7 @@ impl Tool for TicketWorkflowStateTool {
let to = params.to.into_state();
if from == to {
return Err(ToolError::InvalidArgument(
"workflow_state transition must change state".to_string(),
"state transition must change state".to_string(),
));
}
let mut change =
@@ -731,7 +707,7 @@ impl Tool for TicketWorkflowStateTool {
.map_err(|error| backend_error("TicketWorkflowState", error))?;
Ok(json_output(
format!(
"Transitioned ticket {} workflow_state {} -> {}",
"Transitioned ticket {} state {} -> {}",
params.ticket,
from.as_str(),
to.as_str()
@@ -740,7 +716,7 @@ impl Tool for TicketWorkflowStateTool {
"ticket": params.ticket,
"from": from.as_str(),
"to": to.as_str(),
"workflow_state": to.as_str(),
"state": to.as_str(),
"ok": true
}),
))
@@ -751,16 +727,16 @@ impl Tool for TicketWorkflowStateTool {
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 {
let state = match params.state {
TicketStatusParam::Open => TicketStatus::Open,
TicketStatusParam::Pending => TicketStatus::Pending,
};
self.backend
.set_status(TicketIdOrSlug::Query(params.ticket.clone()), status)
.set_status(TicketIdOrSlug::Query(params.ticket.clone()), state)
.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 }),
format!("Moved ticket {} to {}", params.ticket, state.as_str()),
json!({ "ticket": params.ticket, "state": state.as_str(), "ok": true }),
))
}
}
@@ -777,7 +753,7 @@ impl Tool for TicketCloseTool {
.map_err(|error| backend_error("TicketClose", error))?;
Ok(json_output(
format!("Closed ticket {}", params.ticket),
json!({ "ticket": params.ticket, "status": "closed", "ok": true }),
json!({ "ticket": params.ticket, "state": "closed", "ok": true }),
))
}
}
@@ -886,21 +862,15 @@ 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();
fn id_or_query(id: Option<String>, query: Option<String>) -> Result<TicketIdOrSlug, ToolError> {
let provided = id.iter().chain(query.iter()).count();
if provided != 1 {
return Err(ToolError::InvalidArgument(
"exactly one of id, slug, or query must be provided".to_string(),
"exactly one of id 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"),
@@ -908,31 +878,13 @@ fn id_or_slug(
}
}
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,
"state": ticket.workflow_state.as_str(),
"readiness": ticket.readiness,
"action_required": ticket.action_required,
"workflow_state": ticket.workflow_state.as_str(),
"workflow_state_explicit": ticket.workflow_state_explicit,
"attention_required": ticket.attention_required,
"queued_by": ticket.queued_by,
"queued_at": ticket.queued_at,
@@ -956,7 +908,7 @@ fn ticket_json(
"kind": event.kind.as_str(),
"author": event.author,
"at": event.at,
"status": event.status,
"state": event.status,
"from": event.from,
"to": event.to,
"reason": event.reason,
@@ -977,20 +929,14 @@ fn ticket_json(
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,
"state": ticket.meta.workflow_state.as_str(),
"created_at": ticket.meta.created_at,
"updated_at": ticket.meta.updated_at,
"assignee": ticket.meta.assignee,
"readiness": ticket.meta.readiness,
"risk_flags": ticket.meta.risk_flags,
"action_required": ticket.meta.action_required,
"workflow_state": ticket.meta.workflow_state.as_str(),
"workflow_state_explicit": ticket.meta.workflow_state_explicit,
"attention_required": ticket.meta.attention_required,
"queued_by": ticket.meta.queued_by,
"queued_at": ticket.meta.queued_at,
@@ -1227,12 +1173,12 @@ mod tests {
}
#[test]
fn workflow_state_tool_description_explains_queued_acceptance() {
fn state_tool_description_explains_queued_acceptance() {
let temp = TempDir::new().unwrap();
let definition = ticket_tools(backend(&temp))
.into_iter()
.find(|definition| definition().0.name == "TicketWorkflowState")
.expect("workflow state tool exists");
.expect("state tool exists");
let (meta, _) = definition();
assert!(meta.description.contains("queued -> inprogress"));
assert!(meta.description.contains("implementation side effects"));
@@ -1267,7 +1213,7 @@ mod tests {
assert!(!created_text.contains("needs_preflight"));
let listed = list
.execute(&json!({ "status": "open", "label": "tool" }).to_string())
.execute(&json!({ "state": "open", "label": "tool" }).to_string())
.await
.unwrap();
assert!(listed.summary.contains("Listed 1 ticket"));
@@ -1291,13 +1237,13 @@ mod tests {
}
#[tokio::test]
async fn ticket_tools_comment_review_status_and_close_are_doctor_clean() {
async fn ticket_tools_comment_review_state_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 state = tool_by_name(backend.clone(), "TicketStatus");
let close = tool_by_name(backend.clone(), "TicketClose");
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
@@ -1323,8 +1269,8 @@ mod tests {
)
.await
.unwrap();
status
.execute(&json!({ "ticket": created.slug, "status": "pending" }).to_string())
state
.execute(&json!({ "ticket": created.slug, "state": "pending" }).to_string())
.await
.unwrap();
close
@@ -1424,7 +1370,7 @@ mod tests {
.iter()
.filter(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
&& event.state_field.as_deref() == Some("state")
})
.map(|event| (event.from.as_deref(), event.to.as_deref()))
.collect::<Vec<_>>();
@@ -1505,7 +1451,7 @@ mod tests {
}
#[tokio::test]
async fn ticket_workflow_tool_rejects_stale_transition_without_status_move() {
async fn ticket_workflow_tool_rejects_stale_transition_without_state_move() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let created = backend
@@ -1527,17 +1473,13 @@ mod tests {
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("workflow_state changed concurrently")
);
assert!(error.to_string().contains("state changed concurrently"));
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!(!record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
&& event.state_field.as_deref() == Some("state")
}));
}
@@ -1622,16 +1564,12 @@ mod tests {
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("workflow_state changed concurrently")
);
assert!(error.to_string().contains("state changed concurrently"));
let record = backend.show(TicketIdOrSlug::Query(created.slug)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
assert!(!record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
&& event.state_field.as_deref() == Some("state")
}));
}