fix: remove stale ticket status surfaces

This commit is contained in:
2026-06-09 13:04:57 +09:00
parent 591db3ff72
commit 21114fdd6f
6 changed files with 191 additions and 413 deletions
+2 -68
View File
@@ -59,8 +59,6 @@ pub enum TicketError {
query: String,
matches: Vec<PathBuf>,
},
#[error("invalid local ticket status for mutation: {0}")]
InvalidLocalStatus(String),
#[error("invalid ticket filename component: {0}")]
InvalidPathComponent(String),
#[error("ticket path escapes configured root: {path}")]
@@ -83,7 +81,6 @@ fn io_err(path: impl Into<PathBuf>, source: io::Error) -> TicketError {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TicketStatus {
Open,
Pending,
Closed,
}
@@ -91,7 +88,6 @@ impl TicketStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Pending => "pending",
Self::Closed => "closed",
}
}
@@ -99,7 +95,6 @@ impl TicketStatus {
pub fn parse_local(value: &str) -> Option<Self> {
match value {
"open" => Some(Self::Open),
"pending" => Some(Self::Pending),
"closed" => Some(Self::Closed),
_ => None,
}
@@ -115,7 +110,6 @@ impl fmt::Display for TicketStatus {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExtensibleTicketStatus {
Open,
Pending,
Closed,
Other(String),
}
@@ -124,7 +118,6 @@ impl ExtensibleTicketStatus {
pub fn as_str(&self) -> &str {
match self {
Self::Open => "open",
Self::Pending => "pending",
Self::Closed => "closed",
Self::Other(value) => value.as_str(),
}
@@ -133,7 +126,6 @@ impl ExtensibleTicketStatus {
pub fn as_local(&self) -> Option<TicketStatus> {
match self {
Self::Open => Some(TicketStatus::Open),
Self::Pending => Some(TicketStatus::Pending),
Self::Closed => Some(TicketStatus::Closed),
Self::Other(_) => None,
}
@@ -144,7 +136,6 @@ impl From<&str> for ExtensibleTicketStatus {
fn from(value: &str) -> Self {
match value {
"open" => Self::Open,
"pending" => Self::Pending,
"closed" => Self::Closed,
other => Self::Other(other.to_string()),
}
@@ -155,7 +146,6 @@ impl From<TicketStatus> for ExtensibleTicketStatus {
fn from(value: TicketStatus) -> Self {
match value {
TicketStatus::Open => Self::Open,
TicketStatus::Pending => Self::Pending,
TicketStatus::Closed => Self::Closed,
}
}
@@ -530,13 +520,6 @@ impl TicketFilter {
pub fn state(state: TicketWorkflowState) -> Self {
Self { state: Some(state) }
}
pub fn status(status: TicketStatus) -> Self {
match status {
TicketStatus::Closed => Self::state(TicketWorkflowState::Closed),
TicketStatus::Open | TicketStatus::Pending => Self::all(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -791,7 +774,6 @@ pub trait TicketBackend {
) -> Result<()>;
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_orchestration_plan_record(
&self,
@@ -873,19 +855,11 @@ impl LocalTicketBackend {
}
}
fn state_changed_body(&self, state: TicketWorkflowState) -> String {
if is_japanese_record_language(self.record_language()) {
format!("Ticket state を `{}` に変更しました。\n", state.as_str())
} else {
format!("State changed to `{}`.\n", state.as_str())
}
}
fn closed_workflow_state_body(&self) -> &'static str {
if is_japanese_record_language(self.record_language()) {
"Ticket closed; workflow_state を done に設定しました。\n"
"Ticket closedしました。\n"
} else {
"Ticket closed; workflow_state set to done.\n"
"Ticket closed.\n"
}
}
@@ -1441,29 +1415,6 @@ 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 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()?;
@@ -3450,23 +3401,6 @@ state: planning
assert!(matches!(err, TicketError::Locked { .. }));
}
#[test]
fn rejects_unsafe_components_for_status_moves() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("tickets");
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
fs::write(
root.join("20260609-000000-001/item.md"),
"---\ntitle: Safe\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n",
)
.unwrap();
fs::write(root.join("20260609-000000-001/thread.md"), "").unwrap();
let err = LocalTicketBackend::new(&root)
.set_status(TicketIdOrSlug::Id("../bad".into()), TicketStatus::Pending)
.unwrap_err();
assert!(matches!(err, TicketError::InvalidPathComponent(_)));
}
#[test]
fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() {
let temp = TempDir::new().unwrap();
+3 -52
View File
@@ -16,7 +16,7 @@ use crate::{
NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend,
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind,
TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange,
TicketStatus, TicketSummary, TicketWorkflowState,
TicketSummary, TicketWorkflowState,
};
const DEFAULT_LIST_LIMIT: usize = 100;
@@ -30,7 +30,7 @@ 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; 12] = [
pub const TICKET_TOOL_NAMES: [&str; 11] = [
"TicketCreate",
"TicketList",
"TicketShow",
@@ -38,7 +38,6 @@ pub const TICKET_TOOL_NAMES: [&str; 12] = [
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus",
"TicketClose",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
@@ -52,13 +51,12 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketDoctor",
];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
"TicketCreate",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus",
"TicketClose",
"TicketOrchestrationPlanRecord",
];
@@ -84,9 +82,6 @@ const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the
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 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 sets `state: closed`, writes resolution.md, updates item.md, and appends \
a close event.";
@@ -274,21 +269,6 @@ struct TicketReviewParams {
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.
ticket: String,
/// New state. Use `TicketClose` for `closed`.
state: TicketStatusParam,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams {
/// Ticket id.
@@ -482,11 +462,6 @@ struct TicketWorkflowStateTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketStatusTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketCloseTool {
backend: LocalTicketBackend,
@@ -723,24 +698,6 @@ impl Tool for TicketWorkflowStateTool {
}
}
#[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 state = match params.state {
TicketStatusParam::Open => TicketStatus::Open,
TicketStatusParam::Pending => TicketStatus::Pending,
};
self.backend
.set_status(TicketIdOrSlug::Query(params.ticket.clone()), state)
.map_err(|error| backend_error("TicketStatus", error))?;
Ok(json_output(
format!("Moved ticket {} to {}", params.ticket, state.as_str()),
json!({ "ticket": params.ticket, "state": state.as_str(), "ok": true }),
))
}
}
#[async_trait]
impl Tool for TicketCloseTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
@@ -1039,7 +996,6 @@ fn input_schema(name: &str) -> Value {
"TicketWorkflowState" => {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
}
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketOrchestrationPlanRecord" => {
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams))
@@ -1070,7 +1026,6 @@ impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketStatusTool);
impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool);
@@ -1094,7 +1049,6 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
WORKFLOW_STATE_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>(
"TicketOrchestrationPlanRecord",
@@ -1153,7 +1107,6 @@ mod tests {
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus",
"TicketClose",
"TicketOrchestrationPlanRecord"
]
@@ -1351,7 +1304,6 @@ mod tests {
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!(
record
.events
@@ -1469,7 +1421,6 @@ mod tests {
assert!(error.to_string().contains("state changed concurrently"));
let record = backend.show(TicketIdOrSlug::Id(created.id)).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("state")