From 21114fdd6f0539482d683ee5ddd198644a225a27 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 9 Jun 2026 13:04:57 +0900 Subject: [PATCH] fix: remove stale ticket status surfaces --- AGENTS.md | 22 ++-- crates/ticket/src/lib.rs | 70 +---------- crates/ticket/src/tool.rs | 55 +-------- crates/tui/src/multi_pod.rs | 158 ++++++------------------ crates/tui/src/workspace_panel.rs | 103 ++++++---------- crates/yoi/src/ticket_cli.rs | 196 ++++++++++++++++-------------- 6 files changed, 191 insertions(+), 413 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 31825f67..6350d717 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,16 +51,16 @@ docs-only など Nix build の価値が低い変更で省略する場合は、 ### 基本コマンド -- 新規作成: `yoi ticket create --title "..." [--slug slug] [--kind task] [--priority P2] [--label a,b]` -- 一覧: `yoi ticket list [--status open|pending|closed|all]` -- 詳細: `yoi ticket show ` -- コメント / 計画 / 判断 / 実装報告: `yoi ticket comment [--role comment|plan|decision|implementation_report] [--file path]` -- レビュー記録: `yoi ticket review --approve|--request-changes [--file path]` -- 状態変更: `yoi ticket status open|pending` -- 完了: `yoi ticket close [--resolution text|--file path]` +- 新規作成: `yoi ticket create --title "..." [--priority P2]` +- 一覧: `yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all]` +- 詳細: `yoi ticket show ` +- コメント / 計画 / 判断 / 実装報告: `yoi ticket comment [--role comment|plan|decision|implementation_report] [--file path]` +- レビュー記録: `yoi ticket review --approve|--request-changes [--file path]` +- 状態変更: `yoi ticket state planning|ready|queued|inprogress|done` +- 完了: `yoi ticket close [--resolution text|--file path]` - 整合性確認: `yoi ticket doctor` -`yoi ticket` は typed Ticket backend 経由で `.yoi/tickets/{open,pending,closed}//` 配下の `item.md`、`thread.md`、`artifacts/` を扱う。完了時は `resolution.md` も作られる。手でファイルを作るより、原則として `yoi ticket` または Ticket tools を使うこと。 +`yoi ticket` は typed Ticket backend 経由で flat な `.yoi/tickets//` 配下の `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 の粒度 @@ -71,10 +71,10 @@ docs-only など Nix build の価値が低い変更で省略する場合は、 ### ライフサイクル -- 作成: `yoi ticket create ...` で `.yoi/tickets/open/...` を作成し、必要な前提を書いて commit する。 +- 作成: `yoi ticket create ...` で `.yoi/tickets//` を作成し、必要な前提を書いて commit する。出力された canonical ID を以後の操作に使う。 - 詳細化・前提変更: `item.md` を更新し、必要に応じて `yoi ticket comment` で `thread.md` に経緯を残して commit する。 -- レビュー: `yoi ticket review --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。 -- 完了: `yoi ticket close ` で `.yoi/tickets/closed/...` に移動し、`resolution.md` と完了状態を commit する。 +- レビュー: `yoi ticket review --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。 +- 完了: `yoi ticket close ` で `state: closed` と `resolution.md` を同じ flat Ticket ディレクトリに記録して commit する。 worktree と併用して作業を進める場合、必ずブランチを切る前に対象 work item を作成・詳細化して commit してから切ること。 diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 6b607079..307eb1bb 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -59,8 +59,6 @@ pub enum TicketError { query: String, matches: Vec, }, - #[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, 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 { 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 { 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 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(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index a6590692..8434ea8e 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -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, } -#[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 { - 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 { @@ -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 { WORKFLOW_STATE_DESCRIPTION, backend.clone(), ), - tool_definition::("TicketStatus", STATUS_DESCRIPTION, backend.clone()), tool_definition::("TicketClose", CLOSE_DESCRIPTION, backend.clone()), tool_definition::( "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") diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 36e517c3..70bde178 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -22,10 +22,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Widget}; use session_store::FsStore; use ticket::config::TicketConfig; -use ticket::{ - LocalTicketBackend, NewTicketEvent, TicketBackend, TicketEventKind, TicketIdOrSlug, - TicketWorkflowState, -}; +use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowState}; use tokio::net::UnixStream; use unicode_width::UnicodeWidthStr; @@ -984,19 +981,19 @@ impl MultiPodApp { self.notice = Some("Selected Ticket row has no inline action.".to_string()); return None; }; - let (ticket_id, ticket_slug) = { + let ticket_id = { let Some(ticket) = row.ticket.as_ref() else { self.notice = Some("No Ticket action is selected.".to_string()); return None; }; - (ticket.id.clone(), ticket.slug.clone()) + ticket.id.clone() }; let orchestrator = ticket_action_orchestrator_target(&self.panel, &self.list); self.sending = true; self.notice = Some(format!( "Dispatching {} for Ticket {}…", action.label(), - ticket_slug + ticket_id )); Some(TicketActionRequest { 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::Clarify | NextUserAction::Edit @@ -2028,7 +2011,7 @@ async fn dispatch_ticket_action( notice: format!( "{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.", 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("日本語") } -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( ticket: &crate::workspace_panel::TicketPanelEntry, ) -> String { let title = ticket.title.replace(['\r', '\n'], " "); 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/` 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.", - ticket.slug, + "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/` 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.id, 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 { row.ticket .as_ref() - .map(|ticket| { - if ticket.slug.is_empty() { - ticket.id.clone() - } else { - ticket.slug.clone() - } - }) + .map(|ticket| ticket.id.clone()) .unwrap_or_else(|| match &row.key { PanelRowKey::Ticket(id) => id.clone(), PanelRowKey::Pod(name) => name.clone(), @@ -2766,7 +2723,6 @@ fn panel_priority_style(priority: ActionPriority) -> Style { match priority { ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ActionPriority::ReadyForQueue => Style::default().fg(Color::Green), - ActionPriority::Blocked => Style::default().fg(Color::Red), ActionPriority::ActiveWork => Style::default().fg(Color::Cyan), ActionPriority::Background => Style::default().fg(Color::DarkGray), } @@ -2998,12 +2954,12 @@ mod tests { use std::fs; use tempfile::TempDir; use ticket::{ - LocalTicketBackend, MarkdownText, NewTicket, TicketBackend, TicketEventKind, - TicketWorkflowState, + LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, + TicketEventKind, TicketWorkflowState, }; fn ticket_workspace( - slug: &str, + title: &str, state: TicketWorkflowState, configure: impl FnOnce(&mut NewTicket), ) -> (TempDir, String, LocalTicketBackend) { @@ -3015,34 +2971,20 @@ mod tests { ) .unwrap(); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let mut input = NewTicket { - slug: Some(slug.to_string()), - title: "Ready panel ticket".to_string(), - 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(), - }; + let mut input = NewTicket::new(title); + input.body = MarkdownText::from("Ready for panel action"); + input.workflow_state = Some(state); configure(&mut input); let ticket = backend.create(input).unwrap(); (temp, ticket.id, backend) } - fn ready_ticket_workspace(slug: &str) -> (TempDir, String, LocalTicketBackend) { - ticket_workspace(slug, TicketWorkflowState::Ready, |_| {}) + fn ready_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { + ticket_workspace(title, TicketWorkflowState::Ready, |_| {}) } - fn done_ticket_workspace(slug: &str) -> (TempDir, String, LocalTicketBackend) { - ticket_workspace(slug, TicketWorkflowState::Done, |_| {}) + fn done_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { + ticket_workspace(title, TicketWorkflowState::Done, |_| {}) } fn request_for( @@ -3076,7 +3018,6 @@ mod tests { assert!(outcome.notice.contains("queued -> inprogress acceptance")); assert!(!outcome.notice.contains("No implementation was started")); 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.queued_by.as_deref(), Some("workspace-panel")); assert!(ticket.meta.queued_at.is_some()); @@ -3085,7 +3026,7 @@ mod tests { .iter() .find(|event| { 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.to.as_deref() == Some("queued") }) @@ -3104,7 +3045,6 @@ mod tests { assert!(error.to_string().contains("state is ready")); 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!(ticket.resolution.is_none()); } @@ -3125,28 +3065,7 @@ mod tests { assert!(ticket.meta.queued_by.is_none()); assert!(!ticket.events.iter().any(|event| { event.kind == TicketEventKind::StateChanged - && event.workflow_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") + && event.state_field.as_deref() == Some("state") })); } @@ -3159,11 +3078,10 @@ mod tests { .await .unwrap(); - assert!(outcome.notice.contains("Closed Ticket panel-close")); + assert!(outcome.notice.contains("Closed Ticket")); assert!(outcome.notice.contains("state was already done")); 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::Done); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed); let resolution = ticket .resolution .as_ref() @@ -3196,7 +3114,6 @@ mod tests { assert!(error.to_string().contains("action_required is set")); assert!(error.to_string().contains("no close was recorded")); 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!(ticket.resolution.is_none()); } @@ -3218,7 +3135,6 @@ mod tests { assert!(error.to_string().contains("attention_required is set")); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open)); assert!(ticket.resolution.is_none()); } @@ -3241,7 +3157,6 @@ mod tests { assert!(error.to_string().contains("resolution.md already exists")); let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open)); assert_eq!( ticket.resolution.as_ref().unwrap().as_str(), "Already resolved\n" @@ -3375,9 +3290,7 @@ mod tests { .unwrap(); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); 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.labels = vec!["intake".to_string()]; backend.create(ticket).unwrap(); let list = PodList::from_sources( PodVisibilitySource::ResumePicker, @@ -3397,7 +3310,7 @@ mod tests { .collect::>(); let ticket_line = lines .iter() - .position(|line| line.contains("needs-human-reply")) + .position(|line| line.contains("Needs Human Reply")) .unwrap(); let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap(); assert!(ticket_line < pod_line); @@ -3732,7 +3645,7 @@ mod tests { "inprogress", ); let ready_row = panel_test_ticket_row( - "ticket-slug", + "ticket-id", "Long Ticket title that should be rendered after short columns", ActionPriority::ReadyForQueue, NextUserAction::Queue, @@ -3752,7 +3665,7 @@ mod tests { display_column(&review_line, "workspace-panel-composer-targets"), id_start ); - assert_eq!(display_column(&ready_line, "ticket-slug"), id_start); + assert_eq!(display_column(&ready_line, "ticket-id"), id_start); assert_eq!( display_column(&review_line, "Workspace panel composer targets"), title_start @@ -3766,7 +3679,7 @@ mod tests { #[test] fn panel_ticket_title_truncates_after_stable_columns() { let row = panel_test_ticket_row( - "ticket-slug", + "ticket-id", "Very long Ticket title that should truncate only after the aligned short columns", ActionPriority::ReadyForQueue, NextUserAction::Queue, @@ -3778,7 +3691,7 @@ mod tests { assert_eq!(line.width(), 112); assert_eq!( - display_column(&line, "ticket-slug"), + display_column(&line, "ticket-id"), title_start - TICKET_ID_COLUMN_WIDTH - 1 ); assert_eq!(display_column(&line, "Very long Ticket"), title_start); @@ -4639,22 +4552,19 @@ mod tests { } fn panel_test_ticket_row( - slug: &str, + id_suffix: &str, title: &str, priority: ActionPriority, next_action: NextUserAction, - status: &str, + state: &str, ) -> PanelRow { let ticket = crate::workspace_panel::TicketPanelEntry { - id: format!("20260606-000000-{slug}"), - slug: slug.to_string(), + id: format!("20260606-000000-{id_suffix}"), title: title.to_string(), - status: "open".to_string(), - kind: "task".to_string(), priority: "P2".to_string(), - labels: Vec::new(), - state: TicketWorkflowState::parse(status).unwrap_or(TicketWorkflowState::Planning), - state_explicit: true, + workflow_state: TicketWorkflowState::parse(state) + .unwrap_or(TicketWorkflowState::Planning), + workflow_state_explicit: true, attention_required: None, next_action: Some(next_action), updated_at: None, @@ -4668,8 +4578,8 @@ mod tests { key: PanelRowKey::Ticket(ticket.id.clone()), kind: crate::workspace_panel::PanelRowKind::Ticket, title: title.to_string(), - subtitle: Some("slug · priority · latest event".to_string()), - status: status.to_string(), + subtitle: Some("id · priority · latest event".to_string()), + status: state.to_string(), priority, next_action: Some(next_action), ticket: Some(ticket), diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index e9fbf670..88adb445 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -3,8 +3,8 @@ use std::path::{Path, PathBuf}; use protocol::PodStatus; use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; use ticket::{ - ExtensibleTicketStatus, LocalTicketBackend, TicketBackend, TicketError, TicketEvent, - TicketFilter, TicketIdOrSlug, TicketMeta, TicketStatus, TicketSummary, TicketWorkflowState, + LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, + TicketMeta, TicketSummary, TicketWorkflowState, }; use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; @@ -199,7 +199,6 @@ pub(crate) enum PanelRowKind { pub(crate) enum ActionPriority { UserReply, ReadyForQueue, - Blocked, ActiveWork, Background, } @@ -209,7 +208,6 @@ pub(crate) enum NextUserAction { Clarify, Queue, Close, - Defer, Edit, Wait, OpenPod, @@ -221,7 +219,6 @@ impl NextUserAction { Self::Clarify => "Clarify", Self::Queue => "Queue", Self::Close => "Close", - Self::Defer => "Defer", Self::Edit => "Edit", Self::Wait => "Wait", Self::OpenPod => "Open", @@ -232,12 +229,8 @@ impl NextUserAction { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketPanelEntry { pub(crate) id: String, - pub(crate) slug: String, pub(crate) title: String, - pub(crate) status: String, - pub(crate) kind: String, pub(crate) priority: String, - pub(crate) labels: Vec, pub(crate) workflow_state: TicketWorkflowState, pub(crate) workflow_state_explicit: bool, pub(crate) attention_required: Option, @@ -601,7 +594,7 @@ pub(crate) fn build_current_ticket_row( pods: &PodList, ) -> ticket::Result { 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!( "Ticket {ticket_id} is already closed" ))); @@ -659,12 +652,8 @@ fn ticket_row( let latest_event = events.last(); let entry = TicketPanelEntry { id: summary.id.clone(), - slug: String::new(), title: summary.title.clone(), - status: summary.workflow_state.as_str().to_string(), - kind: String::new(), priority: summary.priority.clone(), - labels: Vec::new(), workflow_state: summary.workflow_state, workflow_state_explicit: summary.workflow_state_explicit, attention_required: summary.attention_required.clone(), @@ -703,20 +692,6 @@ struct 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 .attention_required .as_deref() @@ -963,10 +938,6 @@ fn lowercase(value: &str) -> String { } #[allow(dead_code)] -fn _status_label(status: &ExtensibleTicketStatus) -> &str { - status.as_str() -} - #[cfg(test)] mod tests { use super::*; @@ -989,11 +960,9 @@ mod tests { fn create_ticket( backend: &LocalTicketBackend, title: &str, - slug: &str, configure: impl FnOnce(&mut NewTicket), ) { let mut input = NewTicket::new(title); - input.slug = Some(slug.to_string()); configure(&mut input); backend.create(input).unwrap(); } @@ -1032,14 +1001,9 @@ mod tests { fn workspace_panel_without_ticket_config_is_pod_only() { let temp = TempDir::new().unwrap(); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - create_ticket( - &backend, - "Hidden Without Config", - "hidden-without-config", - |input| { - input.action_required = Some("answer me".to_string()); - }, - ); + create_ticket(&backend, "Hidden Without Config", |input| { + input.action_required = Some("answer me".to_string()); + }); let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); @@ -1058,10 +1022,10 @@ mod tests { let temp = TempDir::new().unwrap(); write_ticket_config(temp.path()); 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); }); - create_ticket(&backend, "Needs User", "needs-user", |input| { + create_ticket(&backend, "Needs User", |input| { input.workflow_state = Some(TicketWorkflowState::Ready); input.attention_required = Some("answer clarification".to_string()); }); @@ -1095,22 +1059,15 @@ mod tests { } #[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(); write_ticket_config(temp.path()); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - create_ticket( - &backend, - "Readiness Heuristic", - "readiness-heuristic", - |input| { - 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, "Readiness Heuristic", |input| { + input.readiness = Some("implementation-ready".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); }); @@ -1120,10 +1077,10 @@ mod tests { .iter() .find(|row| row.title == "Readiness Heuristic") .unwrap(); - let label = model + let title = model .rows .iter() - .find(|row| row.title == "Label Heuristic") + .find(|row| row.title == "Queued Words Are Not State") .unwrap(); let queued = model .rows @@ -1131,11 +1088,20 @@ mod tests { .find(|row| row.title == "Queued Explicit") .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!(label.status, "planning"); - assert_eq!(label.next_action, Some(NextUserAction::Clarify)); - assert_eq!(queued.status, "queued"); + assert_eq!( + title.ticket.as_ref().unwrap().workflow_state, + 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)); } @@ -1147,22 +1113,21 @@ mod tests { let ticket_ref = backend .create({ let mut input = NewTicket::new("Null Attention Planning"); - input.slug = Some("null-attention-intake".to_string()); input.workflow_state = Some(TicketWorkflowState::Planning); input }) .unwrap(); let item_path = temp .path() - .join(".yoi/tickets/open") + .join(".yoi/tickets") .join(&ticket_ref.id) .join("item.md"); let item = fs::read_to_string(&item_path).unwrap(); fs::write( &item_path, item.replace( - "workflow_state: planning\ncreated_at:", - "workflow_state: planning\nattention_required: null\ncreated_at:", + "state: planning\ncreated_at:", + "state: planning\nattention_required: null\ncreated_at:", ), ) .unwrap(); @@ -1184,8 +1149,8 @@ mod tests { let temp = TempDir::new().unwrap(); write_ticket_config(temp.path()); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - create_ticket(&backend, "Plain Backlog", "plain-backlog", |_| {}); - create_ticket(&backend, "Done Explicit", "done-explicit", |input| { + create_ticket(&backend, "Plain Backlog", |_| {}); + create_ticket(&backend, "Done Explicit", |input| { input.workflow_state = Some(TicketWorkflowState::Done); }); @@ -1213,7 +1178,7 @@ mod tests { let temp = TempDir::new().unwrap(); write_ticket_config(temp.path()); 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 store = PanelRegistryStore::from_root(temp.path().join("local-registry")); store diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index b91aec82..1e4b404f 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -9,8 +9,8 @@ use ticket::config::{ }; use ticket::{ LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, - TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketReview, - TicketReviewResult, TicketWorkflowState, + TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary, + TicketReview, TicketReviewResult, TicketWorkflowState, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -416,33 +416,52 @@ fn state( backend: &LocalTicketBackend, options: StateOptions, ) -> Result { - let (from, to) = match options.state { - StateTarget::Planning => (TicketWorkflowState::Planning, TicketWorkflowState::Planning), - StateTarget::Ready => (TicketWorkflowState::Planning, TicketWorkflowState::Ready), - StateTarget::Queued => (TicketWorkflowState::Ready, TicketWorkflowState::Queued), - StateTarget::InProgress => (TicketWorkflowState::Queued, TicketWorkflowState::InProgress), - StateTarget::Done => (TicketWorkflowState::InProgress, TicketWorkflowState::Done), + let id = TicketIdOrSlug::Query(options.query.clone()); + let target_state = match options.state { + StateTarget::Planning => TicketWorkflowState::Planning, + StateTarget::Ready => TicketWorkflowState::Ready, + StateTarget::Queued => TicketWorkflowState::Queued, + StateTarget::InProgress => TicketWorkflowState::InProgress, + StateTarget::Done => TicketWorkflowState::Done, StateTarget::Closed => { return Err(TicketCliError::new( "yoi ticket state closed cannot write resolution.md; use `yoi ticket close --resolution ` instead", )); } }; - backend.set_workflow_state( - TicketIdOrSlug::Query(options.query.clone()), - ticket::TicketStateChange { - from: from.as_str().to_string(), - to: to.as_str().to_string(), - reason: "cli_state".to_string(), - author: Some("yoi ticket".to_string()), - body: format!("State changed to `{}`.\n", to.as_str()).into(), - references: Vec::new(), - }, - )?; + let current = backend.show(id.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 { + from: current.meta.workflow_state.as_str().to_string(), + to: TicketWorkflowState::Ready.as_str().to_string(), + reason: "cli_state".to_string(), + author: Some("yoi ticket".to_string()), + body: "Marked ready by `yoi ticket state`.\n".into(), + 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!( "state\t{}\t{}\n", - options.query, - to.as_str() + ticket_id, + target_state.as_str() ))) } @@ -811,6 +830,15 @@ mod tests { 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] fn ticket_cli_init_writes_explicit_ticket_config_scaffold() { let temp = TempDir::new().unwrap(); @@ -862,49 +890,40 @@ mod tests { } #[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 created = run( - &temp, - &[ - "create", - "--title", - "CLI Created", - "--slug", - "cli-created", - "--kind", - "task", - "--priority", - "P1", - "--label", - "ticket,cli", - ], - ); + let created = run(&temp, &["create", "--title", "CLI Created"]); assert_eq!(created.status, TicketCliStatus::Success); assert!(created.stdout.contains("created\t")); - assert!(created.stdout.contains("\tcli-created\topen")); - assert!(temp.path().join(".yoi/tickets/open").exists()); + let ticket_id = created_id(&created); + assert!(temp.path().join(".yoi/tickets").join(&ticket_id).exists()); assert!(!temp.path().join("work-items").exists()); let created_item = fs::read_to_string( temp.path() - .join(".yoi/tickets/open") - .join(created.stdout.split('\t').nth(1).unwrap()) + .join(".yoi/tickets") + .join(&ticket_id) .join("item.md"), ) .unwrap(); + assert!(created_item.contains("state:")); + assert!(created_item.contains("planning")); assert!(!created_item.contains("legacy_ticket:")); assert!(!created_item.contains("needs_preflight:")); + assert!(!created_item.contains("slug:")); + assert!(!created_item.contains("workflow_state:")); - let listed = run(&temp, &["list", "--status", "open"]); - assert!(listed.stdout.contains("status\tid\tslug")); + let listed = run(&temp, &["list", "--state", "planning"]); + assert!(listed.stdout.contains("state\tid\ttitle")); + assert!(listed.stdout.contains(&ticket_id)); assert!(listed.stdout.contains("CLI Created")); assert!(!listed.stdout.contains("legacy_ticket")); 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("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("needs_preflight")); @@ -912,7 +931,7 @@ mod tests { &temp, &[ "comment", - "cli-created", + &ticket_id, "--role", "implementation_report", "--message", @@ -922,44 +941,62 @@ mod tests { assert!( commented .stdout - .contains("appended\tcli-created\timplementation_report") + .contains(&format!("appended\t{}\timplementation_report", ticket_id)) ); let reviewed = run( &temp, &[ "review", - "cli-created", + &ticket_id, "--approve", "--message", "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"]); - assert!(pending.stdout.contains("status\tcli-created\tpending")); + let ready = run(&temp, &["state", &ticket_id, "ready"]); + 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( &temp, - &[ - "close", - "cli-created", - "--resolution", - "Done via yoi ticket.", - ], + &["close", &ticket_id, "--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"]); assert_eq!(doctor.status, TicketCliStatus::Success); assert_eq!(doctor.stdout, "doctor: ok\n"); let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let ticket = backend - .show(TicketIdOrSlug::Query("cli-created".to_string())) - .unwrap(); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); assert!(ticket.resolution.is_some()); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed); assert!( ticket .events @@ -984,28 +1021,11 @@ mod tests { ) .unwrap(); - run( - &temp, - &[ - "create", - "--title", - "Configured Root", - "--slug", - "configured-root", - ], - ); + let created = run(&temp, &["create", "--title", "Configured Root"]); + let ticket_id = created_id(&created); - assert!( - temp.path() - .join("custom-tickets/open") - .read_dir() - .unwrap() - .any(|entry| entry - .unwrap() - .file_name() - .to_string_lossy() - .contains("configured-root")) - ); + assert!(temp.path().join("custom-tickets").join(ticket_id).exists()); + assert!(!temp.path().join("custom-tickets/open").exists()); assert!(!temp.path().join("work-items").exists()); } @@ -1038,13 +1058,11 @@ mod tests { } #[test] - fn ticket_cli_status_closed_requires_close_command() { + fn ticket_cli_state_closed_requires_close_command() { let temp = TempDir::new().unwrap(); - run( - &temp, - &["create", "--title", "Close Me", "--slug", "close-me"], - ); - let cli = parse_ticket_args(&args(&["status", "close-me", "closed"])).unwrap(); + let created = run(&temp, &["create", "--title", "Close Me"]); + let ticket_id = created_id(&created); + let cli = parse_ticket_args(&args(&["state", &ticket_id, "closed"])).unwrap(); let err = run_in_workspace(cli, temp.path()).unwrap_err(); assert!(err.to_string().contains("use `yoi ticket close")); }