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 list [--status open|pending|closed|all]`
- 詳細: `yoi ticket show <id-or-slug>`
- コメント / 計画 / 判断 / 実装報告: `yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] [--file path]`
- レビュー記録: `yoi ticket review <id-or-slug> --approve|--request-changes [--file path]`
- 状態変更: `yoi ticket status <id-or-slug> open|pending`
- 完了: `yoi ticket close <id-or-slug> [--resolution text|--file path]`
- 新規作成: `yoi ticket create --title "..." [--priority P2]`
- 一覧: `yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all]`
- 詳細: `yoi ticket show <ticket-id>`
- コメント / 計画 / 判断 / 実装報告: `yoi ticket comment <ticket-id> [--role comment|plan|decision|implementation_report] [--file path]`
- レビュー記録: `yoi ticket review <ticket-id> --approve|--request-changes [--file path]`
- 状態変更: `yoi ticket state <ticket-id> planning|ready|queued|inprogress|done`
- 完了: `yoi ticket close <ticket-id> [--resolution text|--file path]`
- 整合性確認: `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 の粒度
@ -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 する。
- レビュー: `yoi ticket review <id-or-slug> --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。
- 完了: `yoi ticket close <id-or-slug>` で `.yoi/tickets/closed/...` に移動し、`resolution.md` と完了状態を commit する。
- レビュー: `yoi ticket review <ticket-id> --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。
- 完了: `yoi ticket close <ticket-id>` で `state: closed``resolution.md` を同じ flat Ticket ディレクトリに記録して commit する。
worktree と併用して作業を進める場合、必ずブランチを切る前に対象 work item を作成・詳細化して commit してから切ること。

View File

@ -59,8 +59,6 @@ pub enum TicketError {
query: String,
matches: Vec<PathBuf>,
},
#[error("invalid local ticket status for mutation: {0}")]
InvalidLocalStatus(String),
#[error("invalid ticket filename component: {0}")]
InvalidPathComponent(String),
#[error("ticket path escapes configured root: {path}")]
@ -83,7 +81,6 @@ fn io_err(path: impl Into<PathBuf>, source: io::Error) -> TicketError {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TicketStatus {
Open,
Pending,
Closed,
}
@ -91,7 +88,6 @@ impl TicketStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Pending => "pending",
Self::Closed => "closed",
}
}
@ -99,7 +95,6 @@ impl TicketStatus {
pub fn parse_local(value: &str) -> Option<Self> {
match value {
"open" => Some(Self::Open),
"pending" => Some(Self::Pending),
"closed" => Some(Self::Closed),
_ => None,
}
@ -115,7 +110,6 @@ impl fmt::Display for TicketStatus {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExtensibleTicketStatus {
Open,
Pending,
Closed,
Other(String),
}
@ -124,7 +118,6 @@ impl ExtensibleTicketStatus {
pub fn as_str(&self) -> &str {
match self {
Self::Open => "open",
Self::Pending => "pending",
Self::Closed => "closed",
Self::Other(value) => value.as_str(),
}
@ -133,7 +126,6 @@ impl ExtensibleTicketStatus {
pub fn as_local(&self) -> Option<TicketStatus> {
match self {
Self::Open => Some(TicketStatus::Open),
Self::Pending => Some(TicketStatus::Pending),
Self::Closed => Some(TicketStatus::Closed),
Self::Other(_) => None,
}
@ -144,7 +136,6 @@ impl From<&str> for ExtensibleTicketStatus {
fn from(value: &str) -> Self {
match value {
"open" => Self::Open,
"pending" => Self::Pending,
"closed" => Self::Closed,
other => Self::Other(other.to_string()),
}
@ -155,7 +146,6 @@ impl From<TicketStatus> for ExtensibleTicketStatus {
fn from(value: TicketStatus) -> Self {
match value {
TicketStatus::Open => Self::Open,
TicketStatus::Pending => Self::Pending,
TicketStatus::Closed => Self::Closed,
}
}
@ -530,13 +520,6 @@ impl TicketFilter {
pub fn state(state: TicketWorkflowState) -> Self {
Self { state: Some(state) }
}
pub fn status(status: TicketStatus) -> Self {
match status {
TicketStatus::Closed => Self::state(TicketWorkflowState::Closed),
TicketStatus::Open | TicketStatus::Pending => Self::all(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -791,7 +774,6 @@ pub trait TicketBackend {
) -> Result<()>;
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_orchestration_plan_record(
&self,
@ -873,19 +855,11 @@ impl LocalTicketBackend {
}
}
fn state_changed_body(&self, state: TicketWorkflowState) -> String {
if is_japanese_record_language(self.record_language()) {
format!("Ticket state を `{}` に変更しました。\n", state.as_str())
} else {
format!("State changed to `{}`.\n", state.as_str())
}
}
fn closed_workflow_state_body(&self) -> &'static str {
if is_japanese_record_language(self.record_language()) {
"Ticket closed; workflow_state を done設定しました。\n"
"Ticket を closed にしました。\n"
} else {
"Ticket closed; workflow_state set to done.\n"
"Ticket closed.\n"
}
}
@ -1441,29 +1415,6 @@ impl TicketBackend for LocalTicketBackend {
)
}
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()> {
let target_state = match status {
TicketStatus::Closed => TicketWorkflowState::Closed,
TicketStatus::Open | TicketStatus::Pending => TicketWorkflowState::Planning,
};
let _lock = self.acquire_lock()?;
self.ensure_backend_dirs()?;
let dir = self.find_ticket_dir(&id)?;
let current_state = self.ticket_workflow_state_from_dir(&dir)?;
let at = now_utc();
let change = TicketStateChange::new(
current_state.as_str(),
target_state.as_str(),
"state_changed",
self.state_changed_body(target_state),
);
self.append_state_changed_event(&dir, &change, Some("state"))?;
self.set_frontmatter_fields(
&dir.join("item.md"),
&[(("state"), target_state.as_str()), ("updated_at", &at)],
)
}
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
let _lock = self.acquire_lock()?;
self.ensure_backend_dirs()?;
@ -3450,23 +3401,6 @@ state: planning
assert!(matches!(err, TicketError::Locked { .. }));
}
#[test]
fn rejects_unsafe_components_for_status_moves() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("tickets");
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
fs::write(
root.join("20260609-000000-001/item.md"),
"---\ntitle: Safe\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n",
)
.unwrap();
fs::write(root.join("20260609-000000-001/thread.md"), "").unwrap();
let err = LocalTicketBackend::new(&root)
.set_status(TicketIdOrSlug::Id("../bad".into()), TicketStatus::Pending)
.unwrap_err();
assert!(matches!(err, TicketError::InvalidPathComponent(_)));
}
#[test]
fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() {
let temp = TempDir::new().unwrap();

View File

@ -16,7 +16,7 @@ use crate::{
NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend,
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind,
TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange,
TicketStatus, TicketSummary, TicketWorkflowState,
TicketSummary, TicketWorkflowState,
};
const DEFAULT_LIST_LIMIT: usize = 100;
@ -30,7 +30,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_TOOL_NAMES: [&str; 12] = [
pub const TICKET_TOOL_NAMES: [&str; 11] = [
"TicketCreate",
"TicketList",
"TicketShow",
@ -38,7 +38,6 @@ pub const TICKET_TOOL_NAMES: [&str; 12] = [
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus",
"TicketClose",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
@ -52,13 +51,12 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 4] = [
"TicketDoctor",
];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
"TicketCreate",
"TicketComment",
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus",
"TicketClose",
"TicketOrchestrationPlanRecord",
];
@ -84,9 +82,6 @@ const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statees through the typed \
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
by `yoi ticket doctor`.";
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
a close event.";
@ -274,21 +269,6 @@ struct TicketReviewParams {
author: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketStatusParam {
Open,
Pending,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketStatusParams {
/// Ticket id.
ticket: String,
/// New state. Use `TicketClose` for `closed`.
state: TicketStatusParam,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams {
/// Ticket id.
@ -482,11 +462,6 @@ struct TicketWorkflowStateTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketStatusTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketCloseTool {
backend: LocalTicketBackend,
@ -723,24 +698,6 @@ impl Tool for TicketWorkflowStateTool {
}
}
#[async_trait]
impl Tool for TicketStatusTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
let params: TicketStatusParams = parse_input("TicketStatus", input_json)?;
let state = match params.state {
TicketStatusParam::Open => TicketStatus::Open,
TicketStatusParam::Pending => TicketStatus::Pending,
};
self.backend
.set_status(TicketIdOrSlug::Query(params.ticket.clone()), state)
.map_err(|error| backend_error("TicketStatus", error))?;
Ok(json_output(
format!("Moved ticket {} to {}", params.ticket, state.as_str()),
json!({ "ticket": params.ticket, "state": state.as_str(), "ok": true }),
))
}
}
#[async_trait]
impl Tool for TicketCloseTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
@ -1039,7 +996,6 @@ fn input_schema(name: &str) -> Value {
"TicketWorkflowState" => {
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
}
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
"TicketOrchestrationPlanRecord" => {
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams))
@ -1070,7 +1026,6 @@ impl_from_backend!(TicketCommentTool);
impl_from_backend!(TicketReviewTool);
impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketStatusTool);
impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool);
@ -1094,7 +1049,6 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
WORKFLOW_STATE_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>(
"TicketOrchestrationPlanRecord",
@ -1153,7 +1107,6 @@ mod tests {
"TicketReview",
"TicketIntakeReady",
"TicketWorkflowState",
"TicketStatus",
"TicketClose",
"TicketOrchestrationPlanRecord"
]
@ -1351,7 +1304,6 @@ mod tests {
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!(
record
.events
@ -1469,7 +1421,6 @@ mod tests {
assert!(error.to_string().contains("state changed concurrently"));
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
assert!(!record.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("state")

View File

@ -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/<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.",
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/<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.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::<Vec<_>>();
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),

View File

@ -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<String>,
pub(crate) workflow_state: TicketWorkflowState,
pub(crate) workflow_state_explicit: bool,
pub(crate) attention_required: Option<String>,
@ -601,7 +594,7 @@ pub(crate) fn build_current_ticket_row(
pods: &PodList,
) -> ticket::Result<PanelRow> {
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

View File

@ -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<TicketCliOutput, TicketCliError> {
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 <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` 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"));
}