fix: remove stale ticket status surfaces

This commit is contained in:
Keisuke Hirata 2026-06-09 13:04:57 +09:00
parent 591db3ff72
commit 21114fdd6f
No known key found for this signature in database
6 changed files with 191 additions and 413 deletions

View File

@ -51,16 +51,16 @@ docs-only など Nix build の価値が低い変更で省略する場合は、
### 基本コマンド ### 基本コマンド
- 新規作成: `yoi ticket create --title "..." [--slug slug] [--kind task] [--priority P2] [--label a,b]` - 新規作成: `yoi ticket create --title "..." [--priority P2]`
- 一覧: `yoi ticket list [--status open|pending|closed|all]` - 一覧: `yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all]`
- 詳細: `yoi ticket show <id-or-slug>` - 詳細: `yoi ticket show <ticket-id>`
- コメント / 計画 / 判断 / 実装報告: `yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] [--file path]` - コメント / 計画 / 判断 / 実装報告: `yoi ticket comment <ticket-id> [--role comment|plan|decision|implementation_report] [--file path]`
- レビュー記録: `yoi ticket review <id-or-slug> --approve|--request-changes [--file path]` - レビュー記録: `yoi ticket review <ticket-id> --approve|--request-changes [--file path]`
- 状態変更: `yoi ticket status <id-or-slug> open|pending` - 状態変更: `yoi ticket state <ticket-id> planning|ready|queued|inprogress|done`
- 完了: `yoi ticket close <id-or-slug> [--resolution text|--file path]` - 完了: `yoi ticket close <ticket-id> [--resolution text|--file path]`
- 整合性確認: `yoi ticket doctor` - 整合性確認: `yoi ticket doctor`
`yoi ticket` は typed Ticket backend 経由で `.yoi/tickets/{open,pending,closed}/<id>/` 配下の `item.md`、`thread.md`、`artifacts/` を扱う。完了時は `resolution.md` も作られる。手でファイルを作るより、原則として `yoi ticket` または Ticket tools を使うこと。 `yoi ticket` は typed Ticket backend 経由で flat な `.yoi/tickets/<ticket-id>/` 配下の `item.md`、`thread.md`、`artifacts/` を扱う。Ticket identity はこのディレクトリ名である canonical ID のみで、title/slug words を含む alias や `open`/`pending`/`closed` bucket は現在の authority ではない。現在の lifecycle は frontmatter の `state` だけで表し、`done` と `closed` は区別する。完了時は同じ Ticket ディレクトリ内に `resolution.md` も作られる。手でファイルを作るより、原則として `yoi ticket` または Ticket tools を使うこと。
### Work item の粒度 ### Work item の粒度
@ -71,10 +71,10 @@ docs-only など Nix build の価値が低い変更で省略する場合は、
### ライフサイクル ### ライフサイクル
- 作成: `yoi ticket create ...``.yoi/tickets/open/...` を作成し、必要な前提を書いて commit する。 - 作成: `yoi ticket create ...``.yoi/tickets/<ticket-id>/` を作成し、必要な前提を書いて commit する。出力された canonical ID を以後の操作に使う。
- 詳細化・前提変更: `item.md` を更新し、必要に応じて `yoi ticket comment``thread.md` に経緯を残して commit する。 - 詳細化・前提変更: `item.md` を更新し、必要に応じて `yoi ticket comment``thread.md` に経緯を残して commit する。
- レビュー: `yoi ticket review <id-or-slug> --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。 - レビュー: `yoi ticket review <ticket-id> --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。
- 完了: `yoi ticket close <id-or-slug>` で `.yoi/tickets/closed/...` に移動し、`resolution.md` と完了状態を commit する。 - 完了: `yoi ticket close <ticket-id>` で `state: closed``resolution.md` を同じ flat Ticket ディレクトリに記録して commit する。
worktree と併用して作業を進める場合、必ずブランチを切る前に対象 work item を作成・詳細化して commit してから切ること。 worktree と併用して作業を進める場合、必ずブランチを切る前に対象 work item を作成・詳細化して commit してから切ること。

View File

@ -59,8 +59,6 @@ pub enum TicketError {
query: String, query: String,
matches: Vec<PathBuf>, matches: Vec<PathBuf>,
}, },
#[error("invalid local ticket status for mutation: {0}")]
InvalidLocalStatus(String),
#[error("invalid ticket filename component: {0}")] #[error("invalid ticket filename component: {0}")]
InvalidPathComponent(String), InvalidPathComponent(String),
#[error("ticket path escapes configured root: {path}")] #[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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TicketStatus { pub enum TicketStatus {
Open, Open,
Pending,
Closed, Closed,
} }
@ -91,7 +88,6 @@ impl TicketStatus {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::Open => "open", Self::Open => "open",
Self::Pending => "pending",
Self::Closed => "closed", Self::Closed => "closed",
} }
} }
@ -99,7 +95,6 @@ impl TicketStatus {
pub fn parse_local(value: &str) -> Option<Self> { pub fn parse_local(value: &str) -> Option<Self> {
match value { match value {
"open" => Some(Self::Open), "open" => Some(Self::Open),
"pending" => Some(Self::Pending),
"closed" => Some(Self::Closed), "closed" => Some(Self::Closed),
_ => None, _ => None,
} }
@ -115,7 +110,6 @@ impl fmt::Display for TicketStatus {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExtensibleTicketStatus { pub enum ExtensibleTicketStatus {
Open, Open,
Pending,
Closed, Closed,
Other(String), Other(String),
} }
@ -124,7 +118,6 @@ impl ExtensibleTicketStatus {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
Self::Open => "open", Self::Open => "open",
Self::Pending => "pending",
Self::Closed => "closed", Self::Closed => "closed",
Self::Other(value) => value.as_str(), Self::Other(value) => value.as_str(),
} }
@ -133,7 +126,6 @@ impl ExtensibleTicketStatus {
pub fn as_local(&self) -> Option<TicketStatus> { pub fn as_local(&self) -> Option<TicketStatus> {
match self { match self {
Self::Open => Some(TicketStatus::Open), Self::Open => Some(TicketStatus::Open),
Self::Pending => Some(TicketStatus::Pending),
Self::Closed => Some(TicketStatus::Closed), Self::Closed => Some(TicketStatus::Closed),
Self::Other(_) => None, Self::Other(_) => None,
} }
@ -144,7 +136,6 @@ impl From<&str> for ExtensibleTicketStatus {
fn from(value: &str) -> Self { fn from(value: &str) -> Self {
match value { match value {
"open" => Self::Open, "open" => Self::Open,
"pending" => Self::Pending,
"closed" => Self::Closed, "closed" => Self::Closed,
other => Self::Other(other.to_string()), other => Self::Other(other.to_string()),
} }
@ -155,7 +146,6 @@ impl From<TicketStatus> for ExtensibleTicketStatus {
fn from(value: TicketStatus) -> Self { fn from(value: TicketStatus) -> Self {
match value { match value {
TicketStatus::Open => Self::Open, TicketStatus::Open => Self::Open,
TicketStatus::Pending => Self::Pending,
TicketStatus::Closed => Self::Closed, TicketStatus::Closed => Self::Closed,
} }
} }
@ -530,13 +520,6 @@ impl TicketFilter {
pub fn state(state: TicketWorkflowState) -> Self { pub fn state(state: TicketWorkflowState) -> Self {
Self { state: Some(state) } 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)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -791,7 +774,6 @@ pub trait TicketBackend {
) -> Result<()>; ) -> Result<()>;
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>; fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> 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 close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_orchestration_plan_record( fn add_orchestration_plan_record(
&self, &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 { fn closed_workflow_state_body(&self) -> &'static str {
if is_japanese_record_language(self.record_language()) { if is_japanese_record_language(self.record_language()) {
"Ticket closed; workflow_state を done設定しました。\n" "Ticket を closed にしました。\n"
} else { } 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<()> { fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
let _lock = self.acquire_lock()?; let _lock = self.acquire_lock()?;
self.ensure_backend_dirs()?; self.ensure_backend_dirs()?;
@ -3450,23 +3401,6 @@ state: planning
assert!(matches!(err, TicketError::Locked { .. })); 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] #[test]
fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() { fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -16,7 +16,7 @@ use crate::{
NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend, NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend,
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind,
TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange, TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange,
TicketStatus, TicketSummary, TicketWorkflowState, TicketSummary, TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 100; 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 DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500; const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_TOOL_NAMES: [&str; 12] = [ pub const TICKET_TOOL_NAMES: [&str; 11] = [
"TicketCreate", "TicketCreate",
"TicketList", "TicketList",
"TicketShow", "TicketShow",
@ -38,7 +38,6 @@ pub const TICKET_TOOL_NAMES: [&str; 12] = [
"TicketReview", "TicketReview",
"TicketIntakeReady", "TicketIntakeReady",
"TicketWorkflowState", "TicketWorkflowState",
"TicketStatus",
"TicketClose", "TicketClose",
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery", "TicketOrchestrationPlanQuery",
@ -52,13 +51,12 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketDoctor", "TicketDoctor",
]; ];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
"TicketCreate", "TicketCreate",
"TicketComment", "TicketComment",
"TicketReview", "TicketReview",
"TicketIntakeReady", "TicketIntakeReady",
"TicketWorkflowState", "TicketWorkflowState",
"TicketStatus",
"TicketClose", "TicketClose",
"TicketOrchestrationPlanRecord", "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` \ Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \ 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."; 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 \ 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 \ backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
a close event."; a close event.";
@ -274,21 +269,6 @@ struct TicketReviewParams {
author: Option<String>, 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)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams { struct TicketIntakeReadyParams {
/// Ticket id. /// Ticket id.
@ -482,11 +462,6 @@ struct TicketWorkflowStateTool {
backend: LocalTicketBackend, backend: LocalTicketBackend,
} }
#[derive(Clone)]
struct TicketStatusTool {
backend: LocalTicketBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketCloseTool { struct TicketCloseTool {
backend: LocalTicketBackend, 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] #[async_trait]
impl Tool for TicketCloseTool { impl Tool for TicketCloseTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> { async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
@ -1039,7 +996,6 @@ fn input_schema(name: &str) -> Value {
"TicketWorkflowState" => { "TicketWorkflowState" => {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams)) 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)), "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketOrchestrationPlanRecord" => { "TicketOrchestrationPlanRecord" => {
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams)) serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams))
@ -1070,7 +1026,6 @@ impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketReviewTool); impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketStatusTool);
impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool); impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool); impl_from_backend!(TicketOrchestrationPlanQueryTool);
@ -1094,7 +1049,6 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
WORKFLOW_STATE_DESCRIPTION, WORKFLOW_STATE_DESCRIPTION,
backend.clone(), backend.clone(),
), ),
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()), tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>( tool_definition::<TicketOrchestrationPlanRecordTool>(
"TicketOrchestrationPlanRecord", "TicketOrchestrationPlanRecord",
@ -1153,7 +1107,6 @@ mod tests {
"TicketReview", "TicketReview",
"TicketIntakeReady", "TicketIntakeReady",
"TicketWorkflowState", "TicketWorkflowState",
"TicketStatus",
"TicketClose", "TicketClose",
"TicketOrchestrationPlanRecord" "TicketOrchestrationPlanRecord"
] ]
@ -1351,7 +1304,6 @@ mod tests {
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!( assert!(
record record
.events .events
@ -1469,7 +1421,6 @@ mod tests {
assert!(error.to_string().contains("state changed concurrently")); assert!(error.to_string().contains("state changed concurrently"));
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!(!record.events.iter().any(|event| { assert!(!record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("state") && event.state_field.as_deref() == Some("state")

View File

@ -22,10 +22,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget}; use ratatui::widgets::{Paragraph, Widget};
use session_store::FsStore; use session_store::FsStore;
use ticket::config::TicketConfig; use ticket::config::TicketConfig;
use ticket::{ use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowState};
LocalTicketBackend, NewTicketEvent, TicketBackend, TicketEventKind, TicketIdOrSlug,
TicketWorkflowState,
};
use tokio::net::UnixStream; use tokio::net::UnixStream;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@ -984,19 +981,19 @@ impl MultiPodApp {
self.notice = Some("Selected Ticket row has no inline action.".to_string()); self.notice = Some("Selected Ticket row has no inline action.".to_string());
return None; return None;
}; };
let (ticket_id, ticket_slug) = { let ticket_id = {
let Some(ticket) = row.ticket.as_ref() else { let Some(ticket) = row.ticket.as_ref() else {
self.notice = Some("No Ticket action is selected.".to_string()); self.notice = Some("No Ticket action is selected.".to_string());
return None; return None;
}; };
(ticket.id.clone(), ticket.slug.clone()) ticket.id.clone()
}; };
let orchestrator = ticket_action_orchestrator_target(&self.panel, &self.list); let orchestrator = ticket_action_orchestrator_target(&self.panel, &self.list);
self.sending = true; self.sending = true;
self.notice = Some(format!( self.notice = Some(format!(
"Dispatching {} for Ticket {}…", "Dispatching {} for Ticket {}…",
action.label(), action.label(),
ticket_slug ticket_id
)); ));
Some(TicketActionRequest { Some(TicketActionRequest {
workspace_root: current_workspace_root(), workspace_root: current_workspace_root(),
@ -2006,20 +2003,6 @@ async fn dispatch_ticket_action(
), ),
}) })
} }
NextUserAction::Defer => {
append_panel_decision(
&backend,
&request.ticket_id,
panel_defer_body(current_ticket),
)?;
Ok(TicketActionOutcome {
notice: format!(
"Recorded Panel Defer for Ticket {}; state remains {}.",
current_ticket.id,
current_ticket.workflow_state.as_str()
),
})
}
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"), NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
NextUserAction::Clarify NextUserAction::Clarify
| NextUserAction::Edit | NextUserAction::Edit
@ -2028,7 +2011,7 @@ async fn dispatch_ticket_action(
notice: format!( notice: format!(
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.", "{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
request.action.label(), request.action.label(),
current_ticket.slug current_ticket.id
), ),
}), }),
} }
@ -2123,32 +2106,12 @@ fn is_japanese_ticket_record_language(language: Option<&str>) -> bool {
|| language.contains("日本語") || language.contains("日本語")
} }
fn append_panel_decision(
backend: &LocalTicketBackend,
ticket_id: &str,
body: String,
) -> Result<(), TicketActionError> {
let mut event = NewTicketEvent::new(TicketEventKind::Decision, body);
event.author = Some("workspace-panel".to_string());
backend
.add_event(TicketIdOrSlug::Id(ticket_id.to_owned()), event)
.map_err(|error| TicketActionError::Ticket(error.to_string()))
}
fn panel_defer_body(ticket: &crate::workspace_panel::TicketPanelEntry) -> String {
format!(
"Panel Defer recorded by a human for Ticket `{}` (`{}`). Keep this Ticket out of immediate Orchestrator routing until a later explicit Queue; no scheduler or implementation Pod was started.",
ticket.slug, ticket.id
)
}
fn orchestrator_queue_notification_message( fn orchestrator_queue_notification_message(
ticket: &crate::workspace_panel::TicketPanelEntry, ticket: &crate::workspace_panel::TicketPanelEntry,
) -> String { ) -> String {
let title = ticket.title.replace(['\r', '\n'], " "); let title = ticket.title.replace(['\r', '\n'], " ");
format!( format!(
"Workspace panel Queue for Ticket `{}` (`{}`), title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnPod implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/<task-name>` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Pods (coder narrow child-worktree write scope, reviewer read-only by default) and stop at a merge-ready dossier without merge/close/final approval. If blocked, record a concise reason and leave the Ticket queued or explicitly defer it.", "Workspace panel Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnPod implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/<task-name>` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Pods (coder narrow child-worktree write scope, reviewer read-only by default) and stop at a merge-ready dossier without merge/close/final approval. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.",
ticket.slug,
ticket.id, ticket.id,
title.trim() title.trim()
) )
@ -2713,13 +2676,7 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
fn panel_ticket_reference(row: &PanelRow) -> String { fn panel_ticket_reference(row: &PanelRow) -> String {
row.ticket row.ticket
.as_ref() .as_ref()
.map(|ticket| { .map(|ticket| ticket.id.clone())
if ticket.slug.is_empty() {
ticket.id.clone()
} else {
ticket.slug.clone()
}
})
.unwrap_or_else(|| match &row.key { .unwrap_or_else(|| match &row.key {
PanelRowKey::Ticket(id) => id.clone(), PanelRowKey::Ticket(id) => id.clone(),
PanelRowKey::Pod(name) => name.clone(), PanelRowKey::Pod(name) => name.clone(),
@ -2766,7 +2723,6 @@ fn panel_priority_style(priority: ActionPriority) -> Style {
match priority { match priority {
ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green), ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
ActionPriority::Blocked => Style::default().fg(Color::Red),
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan), ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
ActionPriority::Background => Style::default().fg(Color::DarkGray), ActionPriority::Background => Style::default().fg(Color::DarkGray),
} }
@ -2998,12 +2954,12 @@ mod tests {
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, TicketBackend, TicketEventKind, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend,
TicketWorkflowState, TicketEventKind, TicketWorkflowState,
}; };
fn ticket_workspace( fn ticket_workspace(
slug: &str, title: &str,
state: TicketWorkflowState, state: TicketWorkflowState,
configure: impl FnOnce(&mut NewTicket), configure: impl FnOnce(&mut NewTicket),
) -> (TempDir, String, LocalTicketBackend) { ) -> (TempDir, String, LocalTicketBackend) {
@ -3015,34 +2971,20 @@ mod tests {
) )
.unwrap(); .unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let mut input = NewTicket { let mut input = NewTicket::new(title);
slug: Some(slug.to_string()), input.body = MarkdownText::from("Ready for panel action");
title: "Ready panel ticket".to_string(), input.workflow_state = Some(state);
body: MarkdownText::from("Ready for panel action"),
kind: "task".to_string(),
priority: "P2".to_string(),
author: None,
assignee: None,
labels: Vec::new(),
readiness: None,
action_required: None,
state: Some(state),
attention_required: None,
queued_by: None,
queued_at: None,
risk_flags: Vec::new(),
};
configure(&mut input); configure(&mut input);
let ticket = backend.create(input).unwrap(); let ticket = backend.create(input).unwrap();
(temp, ticket.id, backend) (temp, ticket.id, backend)
} }
fn ready_ticket_workspace(slug: &str) -> (TempDir, String, LocalTicketBackend) { fn ready_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
ticket_workspace(slug, TicketWorkflowState::Ready, |_| {}) ticket_workspace(title, TicketWorkflowState::Ready, |_| {})
} }
fn done_ticket_workspace(slug: &str) -> (TempDir, String, LocalTicketBackend) { fn done_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
ticket_workspace(slug, TicketWorkflowState::Done, |_| {}) ticket_workspace(title, TicketWorkflowState::Done, |_| {})
} }
fn request_for( fn request_for(
@ -3076,7 +3018,6 @@ mod tests {
assert!(outcome.notice.contains("queued -> inprogress acceptance")); assert!(outcome.notice.contains("queued -> inprogress acceptance"));
assert!(!outcome.notice.contains("No implementation was started")); assert!(!outcome.notice.contains("No implementation was started"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued);
assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel"));
assert!(ticket.meta.queued_at.is_some()); assert!(ticket.meta.queued_at.is_some());
@ -3085,7 +3026,7 @@ mod tests {
.iter() .iter()
.find(|event| { .find(|event| {
event.kind == TicketEventKind::StateChanged event.kind == TicketEventKind::StateChanged
&& event.workflow_state_field.as_deref() == Some("state") && event.state_field.as_deref() == Some("state")
&& event.from.as_deref() == Some("ready") && event.from.as_deref() == Some("ready")
&& event.to.as_deref() == Some("queued") && event.to.as_deref() == Some("queued")
}) })
@ -3104,7 +3045,6 @@ mod tests {
assert!(error.to_string().contains("state is ready")); assert!(error.to_string().contains("state is ready"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
assert!(ticket.resolution.is_none()); assert!(ticket.resolution.is_none());
} }
@ -3125,28 +3065,7 @@ mod tests {
assert!(ticket.meta.queued_by.is_none()); assert!(ticket.meta.queued_by.is_none());
assert!(!ticket.events.iter().any(|event| { assert!(!ticket.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged event.kind == TicketEventKind::StateChanged
&& event.workflow_state_field.as_deref() == Some("state") && event.state_field.as_deref() == Some("state")
}));
}
#[tokio::test]
async fn ticket_defer_action_records_decision_for_pending_ticket() {
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-defer");
backend
.set_status(TicketIdOrSlug::Id(ticket_id.clone()), TicketStatus::Pending)
.unwrap();
let outcome =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Defer))
.await
.unwrap();
assert!(outcome.notice.contains("Recorded Panel Defer"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Pending));
assert!(ticket.events.iter().any(|event| {
event.kind == TicketEventKind::Decision
&& event.body.as_str().contains("Panel Defer recorded")
})); }));
} }
@ -3159,11 +3078,10 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert!(outcome.notice.contains("Closed Ticket panel-close")); assert!(outcome.notice.contains("Closed Ticket"));
assert!(outcome.notice.contains("state was already done")); assert!(outcome.notice.contains("state was already done"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Closed)); assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed);
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Done);
let resolution = ticket let resolution = ticket
.resolution .resolution
.as_ref() .as_ref()
@ -3196,7 +3114,6 @@ mod tests {
assert!(error.to_string().contains("action_required is set")); assert!(error.to_string().contains("action_required is set"));
assert!(error.to_string().contains("no close was recorded")); assert!(error.to_string().contains("no close was recorded"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Done); assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Done);
assert!(ticket.resolution.is_none()); assert!(ticket.resolution.is_none());
} }
@ -3218,7 +3135,6 @@ mod tests {
assert!(error.to_string().contains("attention_required is set")); assert!(error.to_string().contains("attention_required is set"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
assert!(ticket.resolution.is_none()); assert!(ticket.resolution.is_none());
} }
@ -3241,7 +3157,6 @@ mod tests {
assert!(error.to_string().contains("resolution.md already exists")); assert!(error.to_string().contains("resolution.md already exists"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
assert_eq!( assert_eq!(
ticket.resolution.as_ref().unwrap().as_str(), ticket.resolution.as_ref().unwrap().as_str(),
"Already resolved\n" "Already resolved\n"
@ -3375,9 +3290,7 @@ mod tests {
.unwrap(); .unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let mut ticket = NewTicket::new("Needs Human Reply"); let mut ticket = NewTicket::new("Needs Human Reply");
ticket.slug = Some("needs-human-reply".to_string());
ticket.action_required = Some("answer intake question".to_string()); ticket.action_required = Some("answer intake question".to_string());
ticket.labels = vec!["intake".to_string()];
backend.create(ticket).unwrap(); backend.create(ticket).unwrap();
let list = PodList::from_sources( let list = PodList::from_sources(
PodVisibilitySource::ResumePicker, PodVisibilitySource::ResumePicker,
@ -3397,7 +3310,7 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let ticket_line = lines let ticket_line = lines
.iter() .iter()
.position(|line| line.contains("needs-human-reply")) .position(|line| line.contains("Needs Human Reply"))
.unwrap(); .unwrap();
let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap(); let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap();
assert!(ticket_line < pod_line); assert!(ticket_line < pod_line);
@ -3732,7 +3645,7 @@ mod tests {
"inprogress", "inprogress",
); );
let ready_row = panel_test_ticket_row( let ready_row = panel_test_ticket_row(
"ticket-slug", "ticket-id",
"Long Ticket title that should be rendered after short columns", "Long Ticket title that should be rendered after short columns",
ActionPriority::ReadyForQueue, ActionPriority::ReadyForQueue,
NextUserAction::Queue, NextUserAction::Queue,
@ -3752,7 +3665,7 @@ mod tests {
display_column(&review_line, "workspace-panel-composer-targets"), display_column(&review_line, "workspace-panel-composer-targets"),
id_start id_start
); );
assert_eq!(display_column(&ready_line, "ticket-slug"), id_start); assert_eq!(display_column(&ready_line, "ticket-id"), id_start);
assert_eq!( assert_eq!(
display_column(&review_line, "Workspace panel composer targets"), display_column(&review_line, "Workspace panel composer targets"),
title_start title_start
@ -3766,7 +3679,7 @@ mod tests {
#[test] #[test]
fn panel_ticket_title_truncates_after_stable_columns() { fn panel_ticket_title_truncates_after_stable_columns() {
let row = panel_test_ticket_row( let row = panel_test_ticket_row(
"ticket-slug", "ticket-id",
"Very long Ticket title that should truncate only after the aligned short columns", "Very long Ticket title that should truncate only after the aligned short columns",
ActionPriority::ReadyForQueue, ActionPriority::ReadyForQueue,
NextUserAction::Queue, NextUserAction::Queue,
@ -3778,7 +3691,7 @@ mod tests {
assert_eq!(line.width(), 112); assert_eq!(line.width(), 112);
assert_eq!( assert_eq!(
display_column(&line, "ticket-slug"), display_column(&line, "ticket-id"),
title_start - TICKET_ID_COLUMN_WIDTH - 1 title_start - TICKET_ID_COLUMN_WIDTH - 1
); );
assert_eq!(display_column(&line, "Very long Ticket"), title_start); assert_eq!(display_column(&line, "Very long Ticket"), title_start);
@ -4639,22 +4552,19 @@ mod tests {
} }
fn panel_test_ticket_row( fn panel_test_ticket_row(
slug: &str, id_suffix: &str,
title: &str, title: &str,
priority: ActionPriority, priority: ActionPriority,
next_action: NextUserAction, next_action: NextUserAction,
status: &str, state: &str,
) -> PanelRow { ) -> PanelRow {
let ticket = crate::workspace_panel::TicketPanelEntry { let ticket = crate::workspace_panel::TicketPanelEntry {
id: format!("20260606-000000-{slug}"), id: format!("20260606-000000-{id_suffix}"),
slug: slug.to_string(),
title: title.to_string(), title: title.to_string(),
status: "open".to_string(),
kind: "task".to_string(),
priority: "P2".to_string(), priority: "P2".to_string(),
labels: Vec::new(), workflow_state: TicketWorkflowState::parse(state)
state: TicketWorkflowState::parse(status).unwrap_or(TicketWorkflowState::Planning), .unwrap_or(TicketWorkflowState::Planning),
state_explicit: true, workflow_state_explicit: true,
attention_required: None, attention_required: None,
next_action: Some(next_action), next_action: Some(next_action),
updated_at: None, updated_at: None,
@ -4668,8 +4578,8 @@ mod tests {
key: PanelRowKey::Ticket(ticket.id.clone()), key: PanelRowKey::Ticket(ticket.id.clone()),
kind: crate::workspace_panel::PanelRowKind::Ticket, kind: crate::workspace_panel::PanelRowKind::Ticket,
title: title.to_string(), title: title.to_string(),
subtitle: Some("slug · priority · latest event".to_string()), subtitle: Some("id · priority · latest event".to_string()),
status: status.to_string(), status: state.to_string(),
priority, priority,
next_action: Some(next_action), next_action: Some(next_action),
ticket: Some(ticket), ticket: Some(ticket),

View File

@ -3,8 +3,8 @@ use std::path::{Path, PathBuf};
use protocol::PodStatus; use protocol::PodStatus;
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
use ticket::{ use ticket::{
ExtensibleTicketStatus, LocalTicketBackend, TicketBackend, TicketError, TicketEvent, LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
TicketFilter, TicketIdOrSlug, TicketMeta, TicketStatus, TicketSummary, TicketWorkflowState, TicketMeta, TicketSummary, TicketWorkflowState,
}; };
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
@ -199,7 +199,6 @@ pub(crate) enum PanelRowKind {
pub(crate) enum ActionPriority { pub(crate) enum ActionPriority {
UserReply, UserReply,
ReadyForQueue, ReadyForQueue,
Blocked,
ActiveWork, ActiveWork,
Background, Background,
} }
@ -209,7 +208,6 @@ pub(crate) enum NextUserAction {
Clarify, Clarify,
Queue, Queue,
Close, Close,
Defer,
Edit, Edit,
Wait, Wait,
OpenPod, OpenPod,
@ -221,7 +219,6 @@ impl NextUserAction {
Self::Clarify => "Clarify", Self::Clarify => "Clarify",
Self::Queue => "Queue", Self::Queue => "Queue",
Self::Close => "Close", Self::Close => "Close",
Self::Defer => "Defer",
Self::Edit => "Edit", Self::Edit => "Edit",
Self::Wait => "Wait", Self::Wait => "Wait",
Self::OpenPod => "Open", Self::OpenPod => "Open",
@ -232,12 +229,8 @@ impl NextUserAction {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketPanelEntry { pub(crate) struct TicketPanelEntry {
pub(crate) id: String, pub(crate) id: String,
pub(crate) slug: String,
pub(crate) title: String, pub(crate) title: String,
pub(crate) status: String,
pub(crate) kind: String,
pub(crate) priority: String, pub(crate) priority: String,
pub(crate) labels: Vec<String>,
pub(crate) workflow_state: TicketWorkflowState, pub(crate) workflow_state: TicketWorkflowState,
pub(crate) workflow_state_explicit: bool, pub(crate) workflow_state_explicit: bool,
pub(crate) attention_required: Option<String>, pub(crate) attention_required: Option<String>,
@ -601,7 +594,7 @@ pub(crate) fn build_current_ticket_row(
pods: &PodList, pods: &PodList,
) -> ticket::Result<PanelRow> { ) -> ticket::Result<PanelRow> {
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.to_owned()))?; let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.to_owned()))?;
if ticket.meta.status.as_local() == Some(TicketStatus::Closed) { if ticket.meta.workflow_state == TicketWorkflowState::Closed {
return Err(TicketError::Conflict(format!( return Err(TicketError::Conflict(format!(
"Ticket {ticket_id} is already closed" "Ticket {ticket_id} is already closed"
))); )));
@ -659,12 +652,8 @@ fn ticket_row(
let latest_event = events.last(); let latest_event = events.last();
let entry = TicketPanelEntry { let entry = TicketPanelEntry {
id: summary.id.clone(), id: summary.id.clone(),
slug: String::new(),
title: summary.title.clone(), title: summary.title.clone(),
status: summary.workflow_state.as_str().to_string(),
kind: String::new(),
priority: summary.priority.clone(), priority: summary.priority.clone(),
labels: Vec::new(),
workflow_state: summary.workflow_state, workflow_state: summary.workflow_state,
workflow_state_explicit: summary.workflow_state_explicit, workflow_state_explicit: summary.workflow_state_explicit,
attention_required: summary.attention_required.clone(), attention_required: summary.attention_required.clone(),
@ -703,20 +692,6 @@ struct DerivedTicketState {
} }
fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState { fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState {
if summary.status.as_local() == Some(TicketStatus::Pending) {
return DerivedTicketState {
kind: PanelRowKind::Blocked,
priority: ActionPriority::Blocked,
action: Some(NextUserAction::Defer),
disabled_reason: Some(
"Pending Ticket is deferred; queueing is disabled until it is reopened and readied."
.to_string(),
),
key_hint: Some("Open/defer operation lives in Ticket controls".to_string()),
blocked_reason: None,
};
}
if let Some(reason) = summary if let Some(reason) = summary
.attention_required .attention_required
.as_deref() .as_deref()
@ -963,10 +938,6 @@ fn lowercase(value: &str) -> String {
} }
#[allow(dead_code)] #[allow(dead_code)]
fn _status_label(status: &ExtensibleTicketStatus) -> &str {
status.as_str()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -989,11 +960,9 @@ mod tests {
fn create_ticket( fn create_ticket(
backend: &LocalTicketBackend, backend: &LocalTicketBackend,
title: &str, title: &str,
slug: &str,
configure: impl FnOnce(&mut NewTicket), configure: impl FnOnce(&mut NewTicket),
) { ) {
let mut input = NewTicket::new(title); let mut input = NewTicket::new(title);
input.slug = Some(slug.to_string());
configure(&mut input); configure(&mut input);
backend.create(input).unwrap(); backend.create(input).unwrap();
} }
@ -1032,14 +1001,9 @@ mod tests {
fn workspace_panel_without_ticket_config_is_pod_only() { fn workspace_panel_without_ticket_config_is_pod_only() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket( create_ticket(&backend, "Hidden Without Config", |input| {
&backend,
"Hidden Without Config",
"hidden-without-config",
|input| {
input.action_required = Some("answer me".to_string()); input.action_required = Some("answer me".to_string());
}, });
);
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
@ -1058,10 +1022,10 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Ready Ticket", "ready-ticket", |input| { create_ticket(&backend, "Ready Ticket", |input| {
input.workflow_state = Some(TicketWorkflowState::Ready); input.workflow_state = Some(TicketWorkflowState::Ready);
}); });
create_ticket(&backend, "Needs User", "needs-user", |input| { create_ticket(&backend, "Needs User", |input| {
input.workflow_state = Some(TicketWorkflowState::Ready); input.workflow_state = Some(TicketWorkflowState::Ready);
input.attention_required = Some("answer clarification".to_string()); input.attention_required = Some("answer clarification".to_string());
}); });
@ -1095,22 +1059,15 @@ mod tests {
} }
#[test] #[test]
fn workspace_panel_does_not_infer_workflow_state_from_labels_readiness_or_thread() { fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket( create_ticket(&backend, "Readiness Heuristic", |input| {
&backend,
"Readiness Heuristic",
"readiness-heuristic",
|input| {
input.readiness = Some("implementation-ready".to_string()); input.readiness = Some("implementation-ready".to_string());
},
);
create_ticket(&backend, "Label Heuristic", "label-heuristic", |input| {
input.labels = vec!["spike".to_string(), "intake".to_string()];
}); });
create_ticket(&backend, "Queued Explicit", "queued-explicit", |input| { create_ticket(&backend, "Queued Words Are Not State", |_| {});
create_ticket(&backend, "Queued Explicit", |input| {
input.workflow_state = Some(TicketWorkflowState::Queued); input.workflow_state = Some(TicketWorkflowState::Queued);
}); });
@ -1120,10 +1077,10 @@ mod tests {
.iter() .iter()
.find(|row| row.title == "Readiness Heuristic") .find(|row| row.title == "Readiness Heuristic")
.unwrap(); .unwrap();
let label = model let title = model
.rows .rows
.iter() .iter()
.find(|row| row.title == "Label Heuristic") .find(|row| row.title == "Queued Words Are Not State")
.unwrap(); .unwrap();
let queued = model let queued = model
.rows .rows
@ -1131,11 +1088,20 @@ mod tests {
.find(|row| row.title == "Queued Explicit") .find(|row| row.title == "Queued Explicit")
.unwrap(); .unwrap();
assert_eq!(readiness.status, "planning"); assert_eq!(
readiness.ticket.as_ref().unwrap().workflow_state,
TicketWorkflowState::Planning
);
assert_eq!(readiness.next_action, Some(NextUserAction::Clarify)); assert_eq!(readiness.next_action, Some(NextUserAction::Clarify));
assert_eq!(label.status, "planning"); assert_eq!(
assert_eq!(label.next_action, Some(NextUserAction::Clarify)); title.ticket.as_ref().unwrap().workflow_state,
assert_eq!(queued.status, "queued"); TicketWorkflowState::Planning
);
assert_eq!(title.next_action, Some(NextUserAction::Clarify));
assert_eq!(
queued.ticket.as_ref().unwrap().workflow_state,
TicketWorkflowState::Queued
);
assert_eq!(queued.next_action, Some(NextUserAction::Wait)); assert_eq!(queued.next_action, Some(NextUserAction::Wait));
} }
@ -1147,22 +1113,21 @@ mod tests {
let ticket_ref = backend let ticket_ref = backend
.create({ .create({
let mut input = NewTicket::new("Null Attention Planning"); let mut input = NewTicket::new("Null Attention Planning");
input.slug = Some("null-attention-intake".to_string());
input.workflow_state = Some(TicketWorkflowState::Planning); input.workflow_state = Some(TicketWorkflowState::Planning);
input input
}) })
.unwrap(); .unwrap();
let item_path = temp let item_path = temp
.path() .path()
.join(".yoi/tickets/open") .join(".yoi/tickets")
.join(&ticket_ref.id) .join(&ticket_ref.id)
.join("item.md"); .join("item.md");
let item = fs::read_to_string(&item_path).unwrap(); let item = fs::read_to_string(&item_path).unwrap();
fs::write( fs::write(
&item_path, &item_path,
item.replace( item.replace(
"workflow_state: planning\ncreated_at:", "state: planning\ncreated_at:",
"workflow_state: planning\nattention_required: null\ncreated_at:", "state: planning\nattention_required: null\ncreated_at:",
), ),
) )
.unwrap(); .unwrap();
@ -1184,8 +1149,8 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Plain Backlog", "plain-backlog", |_| {}); create_ticket(&backend, "Plain Backlog", |_| {});
create_ticket(&backend, "Done Explicit", "done-explicit", |input| { create_ticket(&backend, "Done Explicit", |input| {
input.workflow_state = Some(TicketWorkflowState::Done); input.workflow_state = Some(TicketWorkflowState::Done);
}); });
@ -1213,7 +1178,7 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
write_ticket_config(temp.path()); write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
create_ticket(&backend, "Claimed Planning", "claimed-intake", |_| {}); create_ticket(&backend, "Claimed Planning", |_| {});
let summary = backend.list(TicketFilter::all()).unwrap().remove(0); let summary = backend.list(TicketFilter::all()).unwrap().remove(0);
let store = PanelRegistryStore::from_root(temp.path().join("local-registry")); let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
store store

View File

@ -9,8 +9,8 @@ use ticket::config::{
}; };
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend,
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketReview, TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary,
TicketReviewResult, TicketWorkflowState, TicketReview, TicketReviewResult, TicketWorkflowState,
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -416,33 +416,52 @@ fn state(
backend: &LocalTicketBackend, backend: &LocalTicketBackend,
options: StateOptions, options: StateOptions,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let (from, to) = match options.state { let id = TicketIdOrSlug::Query(options.query.clone());
StateTarget::Planning => (TicketWorkflowState::Planning, TicketWorkflowState::Planning), let target_state = match options.state {
StateTarget::Ready => (TicketWorkflowState::Planning, TicketWorkflowState::Ready), StateTarget::Planning => TicketWorkflowState::Planning,
StateTarget::Queued => (TicketWorkflowState::Ready, TicketWorkflowState::Queued), StateTarget::Ready => TicketWorkflowState::Ready,
StateTarget::InProgress => (TicketWorkflowState::Queued, TicketWorkflowState::InProgress), StateTarget::Queued => TicketWorkflowState::Queued,
StateTarget::Done => (TicketWorkflowState::InProgress, TicketWorkflowState::Done), StateTarget::InProgress => TicketWorkflowState::InProgress,
StateTarget::Done => TicketWorkflowState::Done,
StateTarget::Closed => { StateTarget::Closed => {
return Err(TicketCliError::new( return Err(TicketCliError::new(
"yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead", "yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead",
)); ));
} }
}; };
backend.set_workflow_state( let current = backend.show(id.clone())?;
TicketIdOrSlug::Query(options.query.clone()), let ticket_id = current.meta.id.clone();
match target_state {
TicketWorkflowState::Ready => backend.mark_intake_ready(
id,
TicketIntakeSummary::new("Marked ready by `yoi ticket state`."),
ticket::TicketStateChange { ticket::TicketStateChange {
from: from.as_str().to_string(), from: current.meta.workflow_state.as_str().to_string(),
to: to.as_str().to_string(), to: TicketWorkflowState::Ready.as_str().to_string(),
reason: "cli_state".to_string(), reason: "cli_state".to_string(),
author: Some("yoi ticket".to_string()), author: Some("yoi ticket".to_string()),
body: format!("State changed to `{}`.\n", to.as_str()).into(), body: "Marked ready by `yoi ticket state`.\n".into(),
references: Vec::new(), references: Vec::new(),
}, },
)?; )?,
TicketWorkflowState::Queued => backend.queue_ready(id, "yoi ticket")?,
_ => {
let from = current.meta.workflow_state;
let change = ticket::TicketStateChange {
from: from.as_str().to_string(),
to: target_state.as_str().to_string(),
reason: "cli_state".to_string(),
author: Some("yoi ticket".to_string()),
body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
references: Vec::new(),
};
backend.set_workflow_state(id, change)?;
}
}
Ok(success(format!( Ok(success(format!(
"state\t{}\t{}\n", "state\t{}\t{}\n",
options.query, ticket_id,
to.as_str() target_state.as_str()
))) )))
} }
@ -811,6 +830,15 @@ mod tests {
run_in_workspace(cli, temp.path()).unwrap() run_in_workspace(cli, temp.path()).unwrap()
} }
fn created_id(output: &TicketCliOutput) -> String {
output
.stdout
.strip_prefix("created\t")
.and_then(|rest| rest.lines().next())
.expect("create output contains created id")
.to_string()
}
#[test] #[test]
fn ticket_cli_init_writes_explicit_ticket_config_scaffold() { fn ticket_cli_init_writes_explicit_ticket_config_scaffold() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -862,49 +890,40 @@ mod tests {
} }
#[test] #[test]
fn ticket_cli_create_list_show_comment_review_status_close_and_doctor() { fn ticket_cli_create_list_show_comment_review_state_close_and_doctor() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let created = run( let created = run(&temp, &["create", "--title", "CLI Created"]);
&temp,
&[
"create",
"--title",
"CLI Created",
"--slug",
"cli-created",
"--kind",
"task",
"--priority",
"P1",
"--label",
"ticket,cli",
],
);
assert_eq!(created.status, TicketCliStatus::Success); assert_eq!(created.status, TicketCliStatus::Success);
assert!(created.stdout.contains("created\t")); assert!(created.stdout.contains("created\t"));
assert!(created.stdout.contains("\tcli-created\topen")); let ticket_id = created_id(&created);
assert!(temp.path().join(".yoi/tickets/open").exists()); assert!(temp.path().join(".yoi/tickets").join(&ticket_id).exists());
assert!(!temp.path().join("work-items").exists()); assert!(!temp.path().join("work-items").exists());
let created_item = fs::read_to_string( let created_item = fs::read_to_string(
temp.path() temp.path()
.join(".yoi/tickets/open") .join(".yoi/tickets")
.join(created.stdout.split('\t').nth(1).unwrap()) .join(&ticket_id)
.join("item.md"), .join("item.md"),
) )
.unwrap(); .unwrap();
assert!(created_item.contains("state:"));
assert!(created_item.contains("planning"));
assert!(!created_item.contains("legacy_ticket:")); assert!(!created_item.contains("legacy_ticket:"));
assert!(!created_item.contains("needs_preflight:")); assert!(!created_item.contains("needs_preflight:"));
assert!(!created_item.contains("slug:"));
assert!(!created_item.contains("workflow_state:"));
let listed = run(&temp, &["list", "--status", "open"]); let listed = run(&temp, &["list", "--state", "planning"]);
assert!(listed.stdout.contains("status\tid\tslug")); assert!(listed.stdout.contains("state\tid\ttitle"));
assert!(listed.stdout.contains(&ticket_id));
assert!(listed.stdout.contains("CLI Created")); assert!(listed.stdout.contains("CLI Created"));
assert!(!listed.stdout.contains("legacy_ticket")); assert!(!listed.stdout.contains("legacy_ticket"));
assert!(!listed.stdout.contains("needs_preflight")); assert!(!listed.stdout.contains("needs_preflight"));
let shown = run(&temp, &["show", "cli-created"]); let shown = run(&temp, &["show", &ticket_id]);
assert!(shown.stdout.contains("# CLI Created")); assert!(shown.stdout.contains("# CLI Created"));
assert!(shown.stdout.contains("Labels: ticket, cli")); assert!(shown.stdout.contains(&format!("ID: {ticket_id}")));
assert!(shown.stdout.contains("State: planning"));
assert!(!shown.stdout.contains("legacy_ticket")); assert!(!shown.stdout.contains("legacy_ticket"));
assert!(!shown.stdout.contains("needs_preflight")); assert!(!shown.stdout.contains("needs_preflight"));
@ -912,7 +931,7 @@ mod tests {
&temp, &temp,
&[ &[
"comment", "comment",
"cli-created", &ticket_id,
"--role", "--role",
"implementation_report", "implementation_report",
"--message", "--message",
@ -922,44 +941,62 @@ mod tests {
assert!( assert!(
commented commented
.stdout .stdout
.contains("appended\tcli-created\timplementation_report") .contains(&format!("appended\t{}\timplementation_report", ticket_id))
); );
let reviewed = run( let reviewed = run(
&temp, &temp,
&[ &[
"review", "review",
"cli-created", &ticket_id,
"--approve", "--approve",
"--message", "--message",
"Looks good.", "Looks good.",
], ],
); );
assert!(reviewed.stdout.contains("reviewed\tcli-created\tapprove")); assert!(
reviewed
.stdout
.contains(&format!("reviewed\t{}\tapprove", ticket_id))
);
let pending = run(&temp, &["status", "cli-created", "pending"]); let ready = run(&temp, &["state", &ticket_id, "ready"]);
assert!(pending.stdout.contains("status\tcli-created\tpending")); assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
let ready_listed = run(&temp, &["list", "--state", "ready"]);
assert!(ready_listed.stdout.contains(&ticket_id));
let queued = run(&temp, &["state", &ticket_id, "queued"]);
assert_eq!(queued.stdout, format!("state\t{}\tqueued\n", ticket_id));
let queued_listed = run(&temp, &["list", "--state", "queued"]);
assert!(queued_listed.stdout.contains(&ticket_id));
let inprogress = run(&temp, &["state", &ticket_id, "inprogress"]);
assert_eq!(
inprogress.stdout,
format!("state\t{}\tinprogress\n", ticket_id)
);
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
assert!(inprogress_listed.stdout.contains(&ticket_id));
let done = run(&temp, &["state", &ticket_id, "done"]);
assert_eq!(done.stdout, format!("state\t{}\tdone\n", ticket_id));
let done_listed = run(&temp, &["list", "--state", "done"]);
assert!(done_listed.stdout.contains(&ticket_id));
let closed = run( let closed = run(
&temp, &temp,
&[ &["close", &ticket_id, "--resolution", "Done via yoi ticket."],
"close",
"cli-created",
"--resolution",
"Done via yoi ticket.",
],
); );
assert!(closed.stdout.contains("closed\tcli-created")); assert!(closed.stdout.contains(&format!("closed\t{}", ticket_id)));
let doctor = run(&temp, &["doctor"]); let doctor = run(&temp, &["doctor"]);
assert_eq!(doctor.status, TicketCliStatus::Success); assert_eq!(doctor.status, TicketCliStatus::Success);
assert_eq!(doctor.stdout, "doctor: ok\n"); assert_eq!(doctor.stdout, "doctor: ok\n");
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
let ticket = backend let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
.show(TicketIdOrSlug::Query("cli-created".to_string()))
.unwrap();
assert!(ticket.resolution.is_some()); assert!(ticket.resolution.is_some());
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed);
assert!( assert!(
ticket ticket
.events .events
@ -984,28 +1021,11 @@ mod tests {
) )
.unwrap(); .unwrap();
run( let created = run(&temp, &["create", "--title", "Configured Root"]);
&temp, let ticket_id = created_id(&created);
&[
"create",
"--title",
"Configured Root",
"--slug",
"configured-root",
],
);
assert!( assert!(temp.path().join("custom-tickets").join(ticket_id).exists());
temp.path() assert!(!temp.path().join("custom-tickets/open").exists());
.join("custom-tickets/open")
.read_dir()
.unwrap()
.any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.contains("configured-root"))
);
assert!(!temp.path().join("work-items").exists()); assert!(!temp.path().join("work-items").exists());
} }
@ -1038,13 +1058,11 @@ mod tests {
} }
#[test] #[test]
fn ticket_cli_status_closed_requires_close_command() { fn ticket_cli_state_closed_requires_close_command() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
run( let created = run(&temp, &["create", "--title", "Close Me"]);
&temp, let ticket_id = created_id(&created);
&["create", "--title", "Close Me", "--slug", "close-me"], let cli = parse_ticket_args(&args(&["state", &ticket_id, "closed"])).unwrap();
);
let cli = parse_ticket_args(&args(&["status", "close-me", "closed"])).unwrap();
let err = run_in_workspace(cli, temp.path()).unwrap_err(); let err = run_in_workspace(cli, temp.path()).unwrap_err();
assert!(err.to_string().contains("use `yoi ticket close")); assert!(err.to_string().contains("use `yoi ticket close"));
} }