fix: remove stale ticket status surfaces
This commit is contained in:
parent
591db3ff72
commit
21114fdd6f
22
AGENTS.md
22
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 create --title "..." [--priority P2]`
|
||||||
- 一覧: `yoi ticket list [--status open|pending|closed|all]`
|
- 一覧: `yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all]`
|
||||||
- 詳細: `yoi ticket show <id-or-slug>`
|
- 詳細: `yoi ticket show <ticket-id>`
|
||||||
- コメント / 計画 / 判断 / 実装報告: `yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] [--file path]`
|
- コメント / 計画 / 判断 / 実装報告: `yoi ticket comment <ticket-id> [--role comment|plan|decision|implementation_report] [--file path]`
|
||||||
- レビュー記録: `yoi ticket review <id-or-slug> --approve|--request-changes [--file path]`
|
- レビュー記録: `yoi ticket review <ticket-id> --approve|--request-changes [--file path]`
|
||||||
- 状態変更: `yoi ticket status <id-or-slug> open|pending`
|
- 状態変更: `yoi ticket state <ticket-id> planning|ready|queued|inprogress|done`
|
||||||
- 完了: `yoi ticket close <id-or-slug> [--resolution text|--file path]`
|
- 完了: `yoi ticket close <ticket-id> [--resolution text|--file path]`
|
||||||
- 整合性確認: `yoi ticket doctor`
|
- 整合性確認: `yoi ticket doctor`
|
||||||
|
|
||||||
`yoi ticket` は typed Ticket backend 経由で `.yoi/tickets/{open,pending,closed}/<id>/` 配下の `item.md`、`thread.md`、`artifacts/` を扱う。完了時は `resolution.md` も作られる。手でファイルを作るより、原則として `yoi ticket` または Ticket tools を使うこと。
|
`yoi ticket` は typed Ticket backend 経由で flat な `.yoi/tickets/<ticket-id>/` 配下の `item.md`、`thread.md`、`artifacts/` を扱う。Ticket identity はこのディレクトリ名である canonical ID のみで、title/slug words を含む alias や `open`/`pending`/`closed` bucket は現在の authority ではない。現在の lifecycle は frontmatter の `state` だけで表し、`done` と `closed` は区別する。完了時は同じ Ticket ディレクトリ内に `resolution.md` も作られる。手でファイルを作るより、原則として `yoi ticket` または Ticket tools を使うこと。
|
||||||
|
|
||||||
### Work item の粒度
|
### Work item の粒度
|
||||||
|
|
||||||
|
|
@ -71,10 +71,10 @@ docs-only など Nix build の価値が低い変更で省略する場合は、
|
||||||
|
|
||||||
### ライフサイクル
|
### ライフサイクル
|
||||||
|
|
||||||
- 作成: `yoi ticket create ...` で `.yoi/tickets/open/...` を作成し、必要な前提を書いて commit する。
|
- 作成: `yoi ticket create ...` で `.yoi/tickets/<ticket-id>/` を作成し、必要な前提を書いて commit する。出力された canonical ID を以後の操作に使う。
|
||||||
- 詳細化・前提変更: `item.md` を更新し、必要に応じて `yoi ticket comment` で `thread.md` に経緯を残して commit する。
|
- 詳細化・前提変更: `item.md` を更新し、必要に応じて `yoi ticket comment` で `thread.md` に経緯を残して commit する。
|
||||||
- レビュー: `yoi ticket review <id-or-slug> --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。
|
- レビュー: `yoi ticket review <ticket-id> --approve|--request-changes` で `thread.md` にレビュー結果を追記して commit する。
|
||||||
- 完了: `yoi ticket close <id-or-slug>` で `.yoi/tickets/closed/...` に移動し、`resolution.md` と完了状態を commit する。
|
- 完了: `yoi ticket close <ticket-id>` で `state: closed` と `resolution.md` を同じ flat Ticket ディレクトリに記録して commit する。
|
||||||
|
|
||||||
worktree と併用して作業を進める場合、必ずブランチを切る前に対象 work item を作成・詳細化して commit してから切ること。
|
worktree と併用して作業を進める場合、必ずブランチを切る前に対象 work item を作成・詳細化して commit してから切ること。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,6 @@ pub enum TicketError {
|
||||||
query: String,
|
query: String,
|
||||||
matches: Vec<PathBuf>,
|
matches: Vec<PathBuf>,
|
||||||
},
|
},
|
||||||
#[error("invalid local ticket status for mutation: {0}")]
|
|
||||||
InvalidLocalStatus(String),
|
|
||||||
#[error("invalid ticket filename component: {0}")]
|
#[error("invalid ticket filename component: {0}")]
|
||||||
InvalidPathComponent(String),
|
InvalidPathComponent(String),
|
||||||
#[error("ticket path escapes configured root: {path}")]
|
#[error("ticket path escapes configured root: {path}")]
|
||||||
|
|
@ -83,7 +81,6 @@ fn io_err(path: impl Into<PathBuf>, source: io::Error) -> TicketError {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub enum TicketStatus {
|
pub enum TicketStatus {
|
||||||
Open,
|
Open,
|
||||||
Pending,
|
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +88,6 @@ impl TicketStatus {
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Open => "open",
|
Self::Open => "open",
|
||||||
Self::Pending => "pending",
|
|
||||||
Self::Closed => "closed",
|
Self::Closed => "closed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +95,6 @@ impl TicketStatus {
|
||||||
pub fn parse_local(value: &str) -> Option<Self> {
|
pub fn parse_local(value: &str) -> Option<Self> {
|
||||||
match value {
|
match value {
|
||||||
"open" => Some(Self::Open),
|
"open" => Some(Self::Open),
|
||||||
"pending" => Some(Self::Pending),
|
|
||||||
"closed" => Some(Self::Closed),
|
"closed" => Some(Self::Closed),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +110,6 @@ impl fmt::Display for TicketStatus {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub enum ExtensibleTicketStatus {
|
pub enum ExtensibleTicketStatus {
|
||||||
Open,
|
Open,
|
||||||
Pending,
|
|
||||||
Closed,
|
Closed,
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +118,6 @@ impl ExtensibleTicketStatus {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Open => "open",
|
Self::Open => "open",
|
||||||
Self::Pending => "pending",
|
|
||||||
Self::Closed => "closed",
|
Self::Closed => "closed",
|
||||||
Self::Other(value) => value.as_str(),
|
Self::Other(value) => value.as_str(),
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +126,6 @@ impl ExtensibleTicketStatus {
|
||||||
pub fn as_local(&self) -> Option<TicketStatus> {
|
pub fn as_local(&self) -> Option<TicketStatus> {
|
||||||
match self {
|
match self {
|
||||||
Self::Open => Some(TicketStatus::Open),
|
Self::Open => Some(TicketStatus::Open),
|
||||||
Self::Pending => Some(TicketStatus::Pending),
|
|
||||||
Self::Closed => Some(TicketStatus::Closed),
|
Self::Closed => Some(TicketStatus::Closed),
|
||||||
Self::Other(_) => None,
|
Self::Other(_) => None,
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +136,6 @@ impl From<&str> for ExtensibleTicketStatus {
|
||||||
fn from(value: &str) -> Self {
|
fn from(value: &str) -> Self {
|
||||||
match value {
|
match value {
|
||||||
"open" => Self::Open,
|
"open" => Self::Open,
|
||||||
"pending" => Self::Pending,
|
|
||||||
"closed" => Self::Closed,
|
"closed" => Self::Closed,
|
||||||
other => Self::Other(other.to_string()),
|
other => Self::Other(other.to_string()),
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +146,6 @@ impl From<TicketStatus> for ExtensibleTicketStatus {
|
||||||
fn from(value: TicketStatus) -> Self {
|
fn from(value: TicketStatus) -> Self {
|
||||||
match value {
|
match value {
|
||||||
TicketStatus::Open => Self::Open,
|
TicketStatus::Open => Self::Open,
|
||||||
TicketStatus::Pending => Self::Pending,
|
|
||||||
TicketStatus::Closed => Self::Closed,
|
TicketStatus::Closed => Self::Closed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -530,13 +520,6 @@ impl TicketFilter {
|
||||||
pub fn state(state: TicketWorkflowState) -> Self {
|
pub fn state(state: TicketWorkflowState) -> Self {
|
||||||
Self { state: Some(state) }
|
Self { state: Some(state) }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(status: TicketStatus) -> Self {
|
|
||||||
match status {
|
|
||||||
TicketStatus::Closed => Self::state(TicketWorkflowState::Closed),
|
|
||||||
TicketStatus::Open | TicketStatus::Pending => Self::all(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -791,7 +774,6 @@ pub trait TicketBackend {
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
||||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
|
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
|
||||||
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
||||||
fn add_orchestration_plan_record(
|
fn add_orchestration_plan_record(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -873,19 +855,11 @@ impl LocalTicketBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_changed_body(&self, state: TicketWorkflowState) -> String {
|
|
||||||
if is_japanese_record_language(self.record_language()) {
|
|
||||||
format!("Ticket state を `{}` に変更しました。\n", state.as_str())
|
|
||||||
} else {
|
|
||||||
format!("State changed to `{}`.\n", state.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn closed_workflow_state_body(&self) -> &'static str {
|
fn closed_workflow_state_body(&self) -> &'static str {
|
||||||
if is_japanese_record_language(self.record_language()) {
|
if is_japanese_record_language(self.record_language()) {
|
||||||
"Ticket closed; workflow_state を done に設定しました。\n"
|
"Ticket を closed にしました。\n"
|
||||||
} else {
|
} else {
|
||||||
"Ticket closed; workflow_state set to done.\n"
|
"Ticket closed.\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1441,29 +1415,6 @@ impl TicketBackend for LocalTicketBackend {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()> {
|
|
||||||
let target_state = match status {
|
|
||||||
TicketStatus::Closed => TicketWorkflowState::Closed,
|
|
||||||
TicketStatus::Open | TicketStatus::Pending => TicketWorkflowState::Planning,
|
|
||||||
};
|
|
||||||
let _lock = self.acquire_lock()?;
|
|
||||||
self.ensure_backend_dirs()?;
|
|
||||||
let dir = self.find_ticket_dir(&id)?;
|
|
||||||
let current_state = self.ticket_workflow_state_from_dir(&dir)?;
|
|
||||||
let at = now_utc();
|
|
||||||
let change = TicketStateChange::new(
|
|
||||||
current_state.as_str(),
|
|
||||||
target_state.as_str(),
|
|
||||||
"state_changed",
|
|
||||||
self.state_changed_body(target_state),
|
|
||||||
);
|
|
||||||
self.append_state_changed_event(&dir, &change, Some("state"))?;
|
|
||||||
self.set_frontmatter_fields(
|
|
||||||
&dir.join("item.md"),
|
|
||||||
&[(("state"), target_state.as_str()), ("updated_at", &at)],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
||||||
let _lock = self.acquire_lock()?;
|
let _lock = self.acquire_lock()?;
|
||||||
self.ensure_backend_dirs()?;
|
self.ensure_backend_dirs()?;
|
||||||
|
|
@ -3450,23 +3401,6 @@ state: planning
|
||||||
assert!(matches!(err, TicketError::Locked { .. }));
|
assert!(matches!(err, TicketError::Locked { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_unsafe_components_for_status_moves() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let root = tmp.path().join("tickets");
|
|
||||||
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
|
|
||||||
fs::write(
|
|
||||||
root.join("20260609-000000-001/item.md"),
|
|
||||||
"---\ntitle: Safe\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(root.join("20260609-000000-001/thread.md"), "").unwrap();
|
|
||||||
let err = LocalTicketBackend::new(&root)
|
|
||||||
.set_status(TicketIdOrSlug::Id("../bad".into()), TicketStatus::Pending)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, TicketError::InvalidPathComponent(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() {
|
fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use crate::{
|
||||||
NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend,
|
NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend,
|
||||||
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind,
|
TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind,
|
||||||
TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange,
|
TicketIdOrSlug, TicketIntakeSummary, TicketReview, TicketReviewResult, TicketStateChange,
|
||||||
TicketStatus, TicketSummary, TicketWorkflowState,
|
TicketSummary, TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 100;
|
const DEFAULT_LIST_LIMIT: usize = 100;
|
||||||
|
|
@ -30,7 +30,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
||||||
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||||
|
|
||||||
pub const TICKET_TOOL_NAMES: [&str; 12] = [
|
pub const TICKET_TOOL_NAMES: [&str; 11] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
|
|
@ -38,7 +38,6 @@ pub const TICKET_TOOL_NAMES: [&str; 12] = [
|
||||||
"TicketReview",
|
"TicketReview",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketStatus",
|
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
|
|
@ -52,13 +51,12 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 4] = [
|
||||||
"TicketDoctor",
|
"TicketDoctor",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [
|
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
"TicketReview",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketStatus",
|
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
];
|
];
|
||||||
|
|
@ -84,9 +82,6 @@ const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the
|
||||||
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
|
||||||
as the implementation acceptance step: implementation side effects should happen only after that \
|
as the implementation acceptance step: implementation side effects should happen only after that \
|
||||||
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
|
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
|
||||||
const STATUS_DESCRIPTION: &str = "Move a Ticket between non-closed local statees through the typed \
|
|
||||||
Ticket backend. Use `TicketClose` for closing because closed Tickets require a resolution accepted \
|
|
||||||
by `yoi ticket doctor`.";
|
|
||||||
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
||||||
backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
|
backend. The backend sets `state: closed`, writes resolution.md, updates item.md, and appends \
|
||||||
a close event.";
|
a close event.";
|
||||||
|
|
@ -274,21 +269,6 @@ struct TicketReviewParams {
|
||||||
author: Option<String>,
|
author: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
enum TicketStatusParam {
|
|
||||||
Open,
|
|
||||||
Pending,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
||||||
struct TicketStatusParams {
|
|
||||||
/// Ticket id.
|
|
||||||
ticket: String,
|
|
||||||
/// New state. Use `TicketClose` for `closed`.
|
|
||||||
state: TicketStatusParam,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketIntakeReadyParams {
|
struct TicketIntakeReadyParams {
|
||||||
/// Ticket id.
|
/// Ticket id.
|
||||||
|
|
@ -482,11 +462,6 @@ struct TicketWorkflowStateTool {
|
||||||
backend: LocalTicketBackend,
|
backend: LocalTicketBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct TicketStatusTool {
|
|
||||||
backend: LocalTicketBackend,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketCloseTool {
|
struct TicketCloseTool {
|
||||||
backend: LocalTicketBackend,
|
backend: LocalTicketBackend,
|
||||||
|
|
@ -723,24 +698,6 @@ impl Tool for TicketWorkflowStateTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for TicketStatusTool {
|
|
||||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
|
||||||
let params: TicketStatusParams = parse_input("TicketStatus", input_json)?;
|
|
||||||
let state = match params.state {
|
|
||||||
TicketStatusParam::Open => TicketStatus::Open,
|
|
||||||
TicketStatusParam::Pending => TicketStatus::Pending,
|
|
||||||
};
|
|
||||||
self.backend
|
|
||||||
.set_status(TicketIdOrSlug::Query(params.ticket.clone()), state)
|
|
||||||
.map_err(|error| backend_error("TicketStatus", error))?;
|
|
||||||
Ok(json_output(
|
|
||||||
format!("Moved ticket {} to {}", params.ticket, state.as_str()),
|
|
||||||
json!({ "ticket": params.ticket, "state": state.as_str(), "ok": true }),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for TicketCloseTool {
|
impl Tool for TicketCloseTool {
|
||||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
|
|
@ -1039,7 +996,6 @@ fn input_schema(name: &str) -> Value {
|
||||||
"TicketWorkflowState" => {
|
"TicketWorkflowState" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams))
|
||||||
}
|
}
|
||||||
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
|
|
||||||
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)),
|
||||||
"TicketOrchestrationPlanRecord" => {
|
"TicketOrchestrationPlanRecord" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams))
|
serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams))
|
||||||
|
|
@ -1070,7 +1026,6 @@ impl_from_backend!(TicketCommentTool);
|
||||||
impl_from_backend!(TicketReviewTool);
|
impl_from_backend!(TicketReviewTool);
|
||||||
impl_from_backend!(TicketIntakeReadyTool);
|
impl_from_backend!(TicketIntakeReadyTool);
|
||||||
impl_from_backend!(TicketWorkflowStateTool);
|
impl_from_backend!(TicketWorkflowStateTool);
|
||||||
impl_from_backend!(TicketStatusTool);
|
|
||||||
impl_from_backend!(TicketCloseTool);
|
impl_from_backend!(TicketCloseTool);
|
||||||
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
impl_from_backend!(TicketOrchestrationPlanRecordTool);
|
||||||
impl_from_backend!(TicketOrchestrationPlanQueryTool);
|
impl_from_backend!(TicketOrchestrationPlanQueryTool);
|
||||||
|
|
@ -1094,7 +1049,6 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
|
||||||
WORKFLOW_STATE_DESCRIPTION,
|
WORKFLOW_STATE_DESCRIPTION,
|
||||||
backend.clone(),
|
backend.clone(),
|
||||||
),
|
),
|
||||||
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
|
|
||||||
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
|
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
|
||||||
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
tool_definition::<TicketOrchestrationPlanRecordTool>(
|
||||||
"TicketOrchestrationPlanRecord",
|
"TicketOrchestrationPlanRecord",
|
||||||
|
|
@ -1153,7 +1107,6 @@ mod tests {
|
||||||
"TicketReview",
|
"TicketReview",
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketStatus",
|
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketOrchestrationPlanRecord"
|
"TicketOrchestrationPlanRecord"
|
||||||
]
|
]
|
||||||
|
|
@ -1351,7 +1304,6 @@ mod tests {
|
||||||
|
|
||||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
||||||
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
|
||||||
assert!(
|
assert!(
|
||||||
record
|
record
|
||||||
.events
|
.events
|
||||||
|
|
@ -1469,7 +1421,6 @@ mod tests {
|
||||||
assert!(error.to_string().contains("state changed concurrently"));
|
assert!(error.to_string().contains("state changed concurrently"));
|
||||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||||
assert_eq!(record.meta.status.as_local(), Some(TicketStatus::Open));
|
|
||||||
assert!(!record.events.iter().any(|event| {
|
assert!(!record.events.iter().any(|event| {
|
||||||
event.kind == TicketEventKind::StateChanged
|
event.kind == TicketEventKind::StateChanged
|
||||||
&& event.state_field.as_deref() == Some("state")
|
&& event.state_field.as_deref() == Some("state")
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,7 @@ use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Paragraph, Widget};
|
use ratatui::widgets::{Paragraph, Widget};
|
||||||
use session_store::FsStore;
|
use session_store::FsStore;
|
||||||
use ticket::config::TicketConfig;
|
use ticket::config::TicketConfig;
|
||||||
use ticket::{
|
use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowState};
|
||||||
LocalTicketBackend, NewTicketEvent, TicketBackend, TicketEventKind, TicketIdOrSlug,
|
|
||||||
TicketWorkflowState,
|
|
||||||
};
|
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
|
@ -984,19 +981,19 @@ impl MultiPodApp {
|
||||||
self.notice = Some("Selected Ticket row has no inline action.".to_string());
|
self.notice = Some("Selected Ticket row has no inline action.".to_string());
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let (ticket_id, ticket_slug) = {
|
let ticket_id = {
|
||||||
let Some(ticket) = row.ticket.as_ref() else {
|
let Some(ticket) = row.ticket.as_ref() else {
|
||||||
self.notice = Some("No Ticket action is selected.".to_string());
|
self.notice = Some("No Ticket action is selected.".to_string());
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
(ticket.id.clone(), ticket.slug.clone())
|
ticket.id.clone()
|
||||||
};
|
};
|
||||||
let orchestrator = ticket_action_orchestrator_target(&self.panel, &self.list);
|
let orchestrator = ticket_action_orchestrator_target(&self.panel, &self.list);
|
||||||
self.sending = true;
|
self.sending = true;
|
||||||
self.notice = Some(format!(
|
self.notice = Some(format!(
|
||||||
"Dispatching {} for Ticket {}…",
|
"Dispatching {} for Ticket {}…",
|
||||||
action.label(),
|
action.label(),
|
||||||
ticket_slug
|
ticket_id
|
||||||
));
|
));
|
||||||
Some(TicketActionRequest {
|
Some(TicketActionRequest {
|
||||||
workspace_root: current_workspace_root(),
|
workspace_root: current_workspace_root(),
|
||||||
|
|
@ -2006,20 +2003,6 @@ async fn dispatch_ticket_action(
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
NextUserAction::Defer => {
|
|
||||||
append_panel_decision(
|
|
||||||
&backend,
|
|
||||||
&request.ticket_id,
|
|
||||||
panel_defer_body(current_ticket),
|
|
||||||
)?;
|
|
||||||
Ok(TicketActionOutcome {
|
|
||||||
notice: format!(
|
|
||||||
"Recorded Panel Defer for Ticket {}; state remains {}.",
|
|
||||||
current_ticket.id,
|
|
||||||
current_ticket.workflow_state.as_str()
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
|
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
|
||||||
NextUserAction::Clarify
|
NextUserAction::Clarify
|
||||||
| NextUserAction::Edit
|
| NextUserAction::Edit
|
||||||
|
|
@ -2028,7 +2011,7 @@ async fn dispatch_ticket_action(
|
||||||
notice: format!(
|
notice: format!(
|
||||||
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
|
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
|
||||||
request.action.label(),
|
request.action.label(),
|
||||||
current_ticket.slug
|
current_ticket.id
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
@ -2123,32 +2106,12 @@ fn is_japanese_ticket_record_language(language: Option<&str>) -> bool {
|
||||||
|| language.contains("日本語")
|
|| language.contains("日本語")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_panel_decision(
|
|
||||||
backend: &LocalTicketBackend,
|
|
||||||
ticket_id: &str,
|
|
||||||
body: String,
|
|
||||||
) -> Result<(), TicketActionError> {
|
|
||||||
let mut event = NewTicketEvent::new(TicketEventKind::Decision, body);
|
|
||||||
event.author = Some("workspace-panel".to_string());
|
|
||||||
backend
|
|
||||||
.add_event(TicketIdOrSlug::Id(ticket_id.to_owned()), event)
|
|
||||||
.map_err(|error| TicketActionError::Ticket(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn panel_defer_body(ticket: &crate::workspace_panel::TicketPanelEntry) -> String {
|
|
||||||
format!(
|
|
||||||
"Panel Defer recorded by a human for Ticket `{}` (`{}`). Keep this Ticket out of immediate Orchestrator routing until a later explicit Queue; no scheduler or implementation Pod was started.",
|
|
||||||
ticket.slug, ticket.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn orchestrator_queue_notification_message(
|
fn orchestrator_queue_notification_message(
|
||||||
ticket: &crate::workspace_panel::TicketPanelEntry,
|
ticket: &crate::workspace_panel::TicketPanelEntry,
|
||||||
) -> String {
|
) -> String {
|
||||||
let title = ticket.title.replace(['\r', '\n'], " ");
|
let title = ticket.title.replace(['\r', '\n'], " ");
|
||||||
format!(
|
format!(
|
||||||
"Workspace panel Queue for Ticket `{}` (`{}`), title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnPod implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/<task-name>` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Pods (coder narrow child-worktree write scope, reviewer read-only by default) and stop at a merge-ready dossier without merge/close/final approval. If blocked, record a concise reason and leave the Ticket queued or explicitly defer it.",
|
"Workspace panel Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnPod implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/<task-name>` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Pods (coder narrow child-worktree write scope, reviewer read-only by default) and stop at a merge-ready dossier without merge/close/final approval. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.",
|
||||||
ticket.slug,
|
|
||||||
ticket.id,
|
ticket.id,
|
||||||
title.trim()
|
title.trim()
|
||||||
)
|
)
|
||||||
|
|
@ -2713,13 +2676,7 @@ fn panel_row_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
|
||||||
fn panel_ticket_reference(row: &PanelRow) -> String {
|
fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||||
row.ticket
|
row.ticket
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ticket| {
|
.map(|ticket| ticket.id.clone())
|
||||||
if ticket.slug.is_empty() {
|
|
||||||
ticket.id.clone()
|
|
||||||
} else {
|
|
||||||
ticket.slug.clone()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| match &row.key {
|
.unwrap_or_else(|| match &row.key {
|
||||||
PanelRowKey::Ticket(id) => id.clone(),
|
PanelRowKey::Ticket(id) => id.clone(),
|
||||||
PanelRowKey::Pod(name) => name.clone(),
|
PanelRowKey::Pod(name) => name.clone(),
|
||||||
|
|
@ -2766,7 +2723,6 @@ fn panel_priority_style(priority: ActionPriority) -> Style {
|
||||||
match priority {
|
match priority {
|
||||||
ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
ActionPriority::UserReply => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
|
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
|
||||||
ActionPriority::Blocked => Style::default().fg(Color::Red),
|
|
||||||
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
|
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
|
||||||
ActionPriority::Background => Style::default().fg(Color::DarkGray),
|
ActionPriority::Background => Style::default().fg(Color::DarkGray),
|
||||||
}
|
}
|
||||||
|
|
@ -2998,12 +2954,12 @@ mod tests {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, MarkdownText, NewTicket, TicketBackend, TicketEventKind,
|
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend,
|
||||||
TicketWorkflowState,
|
TicketEventKind, TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn ticket_workspace(
|
fn ticket_workspace(
|
||||||
slug: &str,
|
title: &str,
|
||||||
state: TicketWorkflowState,
|
state: TicketWorkflowState,
|
||||||
configure: impl FnOnce(&mut NewTicket),
|
configure: impl FnOnce(&mut NewTicket),
|
||||||
) -> (TempDir, String, LocalTicketBackend) {
|
) -> (TempDir, String, LocalTicketBackend) {
|
||||||
|
|
@ -3015,34 +2971,20 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
let mut input = NewTicket {
|
let mut input = NewTicket::new(title);
|
||||||
slug: Some(slug.to_string()),
|
input.body = MarkdownText::from("Ready for panel action");
|
||||||
title: "Ready panel ticket".to_string(),
|
input.workflow_state = Some(state);
|
||||||
body: MarkdownText::from("Ready for panel action"),
|
|
||||||
kind: "task".to_string(),
|
|
||||||
priority: "P2".to_string(),
|
|
||||||
author: None,
|
|
||||||
assignee: None,
|
|
||||||
labels: Vec::new(),
|
|
||||||
readiness: None,
|
|
||||||
action_required: None,
|
|
||||||
state: Some(state),
|
|
||||||
attention_required: None,
|
|
||||||
queued_by: None,
|
|
||||||
queued_at: None,
|
|
||||||
risk_flags: Vec::new(),
|
|
||||||
};
|
|
||||||
configure(&mut input);
|
configure(&mut input);
|
||||||
let ticket = backend.create(input).unwrap();
|
let ticket = backend.create(input).unwrap();
|
||||||
(temp, ticket.id, backend)
|
(temp, ticket.id, backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ready_ticket_workspace(slug: &str) -> (TempDir, String, LocalTicketBackend) {
|
fn ready_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
|
||||||
ticket_workspace(slug, TicketWorkflowState::Ready, |_| {})
|
ticket_workspace(title, TicketWorkflowState::Ready, |_| {})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn done_ticket_workspace(slug: &str) -> (TempDir, String, LocalTicketBackend) {
|
fn done_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
|
||||||
ticket_workspace(slug, TicketWorkflowState::Done, |_| {})
|
ticket_workspace(title, TicketWorkflowState::Done, |_| {})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_for(
|
fn request_for(
|
||||||
|
|
@ -3076,7 +3018,6 @@ mod tests {
|
||||||
assert!(outcome.notice.contains("queued -> inprogress acceptance"));
|
assert!(outcome.notice.contains("queued -> inprogress acceptance"));
|
||||||
assert!(!outcome.notice.contains("No implementation was started"));
|
assert!(!outcome.notice.contains("No implementation was started"));
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
||||||
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
|
|
||||||
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued);
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued);
|
||||||
assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel"));
|
assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel"));
|
||||||
assert!(ticket.meta.queued_at.is_some());
|
assert!(ticket.meta.queued_at.is_some());
|
||||||
|
|
@ -3085,7 +3026,7 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|event| {
|
.find(|event| {
|
||||||
event.kind == TicketEventKind::StateChanged
|
event.kind == TicketEventKind::StateChanged
|
||||||
&& event.workflow_state_field.as_deref() == Some("state")
|
&& event.state_field.as_deref() == Some("state")
|
||||||
&& event.from.as_deref() == Some("ready")
|
&& event.from.as_deref() == Some("ready")
|
||||||
&& event.to.as_deref() == Some("queued")
|
&& event.to.as_deref() == Some("queued")
|
||||||
})
|
})
|
||||||
|
|
@ -3104,7 +3045,6 @@ mod tests {
|
||||||
|
|
||||||
assert!(error.to_string().contains("state is ready"));
|
assert!(error.to_string().contains("state is ready"));
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
||||||
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
|
|
||||||
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
|
||||||
assert!(ticket.resolution.is_none());
|
assert!(ticket.resolution.is_none());
|
||||||
}
|
}
|
||||||
|
|
@ -3125,28 +3065,7 @@ mod tests {
|
||||||
assert!(ticket.meta.queued_by.is_none());
|
assert!(ticket.meta.queued_by.is_none());
|
||||||
assert!(!ticket.events.iter().any(|event| {
|
assert!(!ticket.events.iter().any(|event| {
|
||||||
event.kind == TicketEventKind::StateChanged
|
event.kind == TicketEventKind::StateChanged
|
||||||
&& event.workflow_state_field.as_deref() == Some("state")
|
&& event.state_field.as_deref() == Some("state")
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn ticket_defer_action_records_decision_for_pending_ticket() {
|
|
||||||
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-defer");
|
|
||||||
backend
|
|
||||||
.set_status(TicketIdOrSlug::Id(ticket_id.clone()), TicketStatus::Pending)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let outcome =
|
|
||||||
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Defer))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(outcome.notice.contains("Recorded Panel Defer"));
|
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
||||||
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Pending));
|
|
||||||
assert!(ticket.events.iter().any(|event| {
|
|
||||||
event.kind == TicketEventKind::Decision
|
|
||||||
&& event.body.as_str().contains("Panel Defer recorded")
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3159,11 +3078,10 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(outcome.notice.contains("Closed Ticket panel-close"));
|
assert!(outcome.notice.contains("Closed Ticket"));
|
||||||
assert!(outcome.notice.contains("state was already done"));
|
assert!(outcome.notice.contains("state was already done"));
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
||||||
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Closed));
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed);
|
||||||
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Done);
|
|
||||||
let resolution = ticket
|
let resolution = ticket
|
||||||
.resolution
|
.resolution
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -3196,7 +3114,6 @@ mod tests {
|
||||||
assert!(error.to_string().contains("action_required is set"));
|
assert!(error.to_string().contains("action_required is set"));
|
||||||
assert!(error.to_string().contains("no close was recorded"));
|
assert!(error.to_string().contains("no close was recorded"));
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
||||||
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
|
|
||||||
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Done);
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Done);
|
||||||
assert!(ticket.resolution.is_none());
|
assert!(ticket.resolution.is_none());
|
||||||
}
|
}
|
||||||
|
|
@ -3218,7 +3135,6 @@ mod tests {
|
||||||
|
|
||||||
assert!(error.to_string().contains("attention_required is set"));
|
assert!(error.to_string().contains("attention_required is set"));
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
||||||
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
|
|
||||||
assert!(ticket.resolution.is_none());
|
assert!(ticket.resolution.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3241,7 +3157,6 @@ mod tests {
|
||||||
|
|
||||||
assert!(error.to_string().contains("resolution.md already exists"));
|
assert!(error.to_string().contains("resolution.md already exists"));
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
||||||
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ticket.resolution.as_ref().unwrap().as_str(),
|
ticket.resolution.as_ref().unwrap().as_str(),
|
||||||
"Already resolved\n"
|
"Already resolved\n"
|
||||||
|
|
@ -3375,9 +3290,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
let mut ticket = NewTicket::new("Needs Human Reply");
|
let mut ticket = NewTicket::new("Needs Human Reply");
|
||||||
ticket.slug = Some("needs-human-reply".to_string());
|
|
||||||
ticket.action_required = Some("answer intake question".to_string());
|
ticket.action_required = Some("answer intake question".to_string());
|
||||||
ticket.labels = vec!["intake".to_string()];
|
|
||||||
backend.create(ticket).unwrap();
|
backend.create(ticket).unwrap();
|
||||||
let list = PodList::from_sources(
|
let list = PodList::from_sources(
|
||||||
PodVisibilitySource::ResumePicker,
|
PodVisibilitySource::ResumePicker,
|
||||||
|
|
@ -3397,7 +3310,7 @@ mod tests {
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let ticket_line = lines
|
let ticket_line = lines
|
||||||
.iter()
|
.iter()
|
||||||
.position(|line| line.contains("needs-human-reply"))
|
.position(|line| line.contains("Needs Human Reply"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap();
|
let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap();
|
||||||
assert!(ticket_line < pod_line);
|
assert!(ticket_line < pod_line);
|
||||||
|
|
@ -3732,7 +3645,7 @@ mod tests {
|
||||||
"inprogress",
|
"inprogress",
|
||||||
);
|
);
|
||||||
let ready_row = panel_test_ticket_row(
|
let ready_row = panel_test_ticket_row(
|
||||||
"ticket-slug",
|
"ticket-id",
|
||||||
"Long Ticket title that should be rendered after short columns",
|
"Long Ticket title that should be rendered after short columns",
|
||||||
ActionPriority::ReadyForQueue,
|
ActionPriority::ReadyForQueue,
|
||||||
NextUserAction::Queue,
|
NextUserAction::Queue,
|
||||||
|
|
@ -3752,7 +3665,7 @@ mod tests {
|
||||||
display_column(&review_line, "workspace-panel-composer-targets"),
|
display_column(&review_line, "workspace-panel-composer-targets"),
|
||||||
id_start
|
id_start
|
||||||
);
|
);
|
||||||
assert_eq!(display_column(&ready_line, "ticket-slug"), id_start);
|
assert_eq!(display_column(&ready_line, "ticket-id"), id_start);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
display_column(&review_line, "Workspace panel composer targets"),
|
display_column(&review_line, "Workspace panel composer targets"),
|
||||||
title_start
|
title_start
|
||||||
|
|
@ -3766,7 +3679,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn panel_ticket_title_truncates_after_stable_columns() {
|
fn panel_ticket_title_truncates_after_stable_columns() {
|
||||||
let row = panel_test_ticket_row(
|
let row = panel_test_ticket_row(
|
||||||
"ticket-slug",
|
"ticket-id",
|
||||||
"Very long Ticket title that should truncate only after the aligned short columns",
|
"Very long Ticket title that should truncate only after the aligned short columns",
|
||||||
ActionPriority::ReadyForQueue,
|
ActionPriority::ReadyForQueue,
|
||||||
NextUserAction::Queue,
|
NextUserAction::Queue,
|
||||||
|
|
@ -3778,7 +3691,7 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(line.width(), 112);
|
assert_eq!(line.width(), 112);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
display_column(&line, "ticket-slug"),
|
display_column(&line, "ticket-id"),
|
||||||
title_start - TICKET_ID_COLUMN_WIDTH - 1
|
title_start - TICKET_ID_COLUMN_WIDTH - 1
|
||||||
);
|
);
|
||||||
assert_eq!(display_column(&line, "Very long Ticket"), title_start);
|
assert_eq!(display_column(&line, "Very long Ticket"), title_start);
|
||||||
|
|
@ -4639,22 +4552,19 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_test_ticket_row(
|
fn panel_test_ticket_row(
|
||||||
slug: &str,
|
id_suffix: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
priority: ActionPriority,
|
priority: ActionPriority,
|
||||||
next_action: NextUserAction,
|
next_action: NextUserAction,
|
||||||
status: &str,
|
state: &str,
|
||||||
) -> PanelRow {
|
) -> PanelRow {
|
||||||
let ticket = crate::workspace_panel::TicketPanelEntry {
|
let ticket = crate::workspace_panel::TicketPanelEntry {
|
||||||
id: format!("20260606-000000-{slug}"),
|
id: format!("20260606-000000-{id_suffix}"),
|
||||||
slug: slug.to_string(),
|
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
status: "open".to_string(),
|
|
||||||
kind: "task".to_string(),
|
|
||||||
priority: "P2".to_string(),
|
priority: "P2".to_string(),
|
||||||
labels: Vec::new(),
|
workflow_state: TicketWorkflowState::parse(state)
|
||||||
state: TicketWorkflowState::parse(status).unwrap_or(TicketWorkflowState::Planning),
|
.unwrap_or(TicketWorkflowState::Planning),
|
||||||
state_explicit: true,
|
workflow_state_explicit: true,
|
||||||
attention_required: None,
|
attention_required: None,
|
||||||
next_action: Some(next_action),
|
next_action: Some(next_action),
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
|
|
@ -4668,8 +4578,8 @@ mod tests {
|
||||||
key: PanelRowKey::Ticket(ticket.id.clone()),
|
key: PanelRowKey::Ticket(ticket.id.clone()),
|
||||||
kind: crate::workspace_panel::PanelRowKind::Ticket,
|
kind: crate::workspace_panel::PanelRowKind::Ticket,
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
subtitle: Some("slug · priority · latest event".to_string()),
|
subtitle: Some("id · priority · latest event".to_string()),
|
||||||
status: status.to_string(),
|
status: state.to_string(),
|
||||||
priority,
|
priority,
|
||||||
next_action: Some(next_action),
|
next_action: Some(next_action),
|
||||||
ticket: Some(ticket),
|
ticket: Some(ticket),
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ use std::path::{Path, PathBuf};
|
||||||
use protocol::PodStatus;
|
use protocol::PodStatus;
|
||||||
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
ExtensibleTicketStatus, LocalTicketBackend, TicketBackend, TicketError, TicketEvent,
|
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
||||||
TicketFilter, TicketIdOrSlug, TicketMeta, TicketStatus, TicketSummary, TicketWorkflowState,
|
TicketMeta, TicketSummary, TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
||||||
|
|
@ -199,7 +199,6 @@ pub(crate) enum PanelRowKind {
|
||||||
pub(crate) enum ActionPriority {
|
pub(crate) enum ActionPriority {
|
||||||
UserReply,
|
UserReply,
|
||||||
ReadyForQueue,
|
ReadyForQueue,
|
||||||
Blocked,
|
|
||||||
ActiveWork,
|
ActiveWork,
|
||||||
Background,
|
Background,
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +208,6 @@ pub(crate) enum NextUserAction {
|
||||||
Clarify,
|
Clarify,
|
||||||
Queue,
|
Queue,
|
||||||
Close,
|
Close,
|
||||||
Defer,
|
|
||||||
Edit,
|
Edit,
|
||||||
Wait,
|
Wait,
|
||||||
OpenPod,
|
OpenPod,
|
||||||
|
|
@ -221,7 +219,6 @@ impl NextUserAction {
|
||||||
Self::Clarify => "Clarify",
|
Self::Clarify => "Clarify",
|
||||||
Self::Queue => "Queue",
|
Self::Queue => "Queue",
|
||||||
Self::Close => "Close",
|
Self::Close => "Close",
|
||||||
Self::Defer => "Defer",
|
|
||||||
Self::Edit => "Edit",
|
Self::Edit => "Edit",
|
||||||
Self::Wait => "Wait",
|
Self::Wait => "Wait",
|
||||||
Self::OpenPod => "Open",
|
Self::OpenPod => "Open",
|
||||||
|
|
@ -232,12 +229,8 @@ impl NextUserAction {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct TicketPanelEntry {
|
pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
pub(crate) slug: String,
|
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) status: String,
|
|
||||||
pub(crate) kind: String,
|
|
||||||
pub(crate) priority: String,
|
pub(crate) priority: String,
|
||||||
pub(crate) labels: Vec<String>,
|
|
||||||
pub(crate) workflow_state: TicketWorkflowState,
|
pub(crate) workflow_state: TicketWorkflowState,
|
||||||
pub(crate) workflow_state_explicit: bool,
|
pub(crate) workflow_state_explicit: bool,
|
||||||
pub(crate) attention_required: Option<String>,
|
pub(crate) attention_required: Option<String>,
|
||||||
|
|
@ -601,7 +594,7 @@ pub(crate) fn build_current_ticket_row(
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
) -> ticket::Result<PanelRow> {
|
) -> ticket::Result<PanelRow> {
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.to_owned()))?;
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.to_owned()))?;
|
||||||
if ticket.meta.status.as_local() == Some(TicketStatus::Closed) {
|
if ticket.meta.workflow_state == TicketWorkflowState::Closed {
|
||||||
return Err(TicketError::Conflict(format!(
|
return Err(TicketError::Conflict(format!(
|
||||||
"Ticket {ticket_id} is already closed"
|
"Ticket {ticket_id} is already closed"
|
||||||
)));
|
)));
|
||||||
|
|
@ -659,12 +652,8 @@ fn ticket_row(
|
||||||
let latest_event = events.last();
|
let latest_event = events.last();
|
||||||
let entry = TicketPanelEntry {
|
let entry = TicketPanelEntry {
|
||||||
id: summary.id.clone(),
|
id: summary.id.clone(),
|
||||||
slug: String::new(),
|
|
||||||
title: summary.title.clone(),
|
title: summary.title.clone(),
|
||||||
status: summary.workflow_state.as_str().to_string(),
|
|
||||||
kind: String::new(),
|
|
||||||
priority: summary.priority.clone(),
|
priority: summary.priority.clone(),
|
||||||
labels: Vec::new(),
|
|
||||||
workflow_state: summary.workflow_state,
|
workflow_state: summary.workflow_state,
|
||||||
workflow_state_explicit: summary.workflow_state_explicit,
|
workflow_state_explicit: summary.workflow_state_explicit,
|
||||||
attention_required: summary.attention_required.clone(),
|
attention_required: summary.attention_required.clone(),
|
||||||
|
|
@ -703,20 +692,6 @@ struct DerivedTicketState {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState {
|
fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState {
|
||||||
if summary.status.as_local() == Some(TicketStatus::Pending) {
|
|
||||||
return DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Blocked,
|
|
||||||
priority: ActionPriority::Blocked,
|
|
||||||
action: Some(NextUserAction::Defer),
|
|
||||||
disabled_reason: Some(
|
|
||||||
"Pending Ticket is deferred; queueing is disabled until it is reopened and readied."
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
key_hint: Some("Open/defer operation lives in Ticket controls".to_string()),
|
|
||||||
blocked_reason: None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(reason) = summary
|
if let Some(reason) = summary
|
||||||
.attention_required
|
.attention_required
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
@ -963,10 +938,6 @@ fn lowercase(value: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn _status_label(status: &ExtensibleTicketStatus) -> &str {
|
|
||||||
status.as_str()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -989,11 +960,9 @@ mod tests {
|
||||||
fn create_ticket(
|
fn create_ticket(
|
||||||
backend: &LocalTicketBackend,
|
backend: &LocalTicketBackend,
|
||||||
title: &str,
|
title: &str,
|
||||||
slug: &str,
|
|
||||||
configure: impl FnOnce(&mut NewTicket),
|
configure: impl FnOnce(&mut NewTicket),
|
||||||
) {
|
) {
|
||||||
let mut input = NewTicket::new(title);
|
let mut input = NewTicket::new(title);
|
||||||
input.slug = Some(slug.to_string());
|
|
||||||
configure(&mut input);
|
configure(&mut input);
|
||||||
backend.create(input).unwrap();
|
backend.create(input).unwrap();
|
||||||
}
|
}
|
||||||
|
|
@ -1032,14 +1001,9 @@ mod tests {
|
||||||
fn workspace_panel_without_ticket_config_is_pod_only() {
|
fn workspace_panel_without_ticket_config_is_pod_only() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
create_ticket(
|
create_ticket(&backend, "Hidden Without Config", |input| {
|
||||||
&backend,
|
input.action_required = Some("answer me".to_string());
|
||||||
"Hidden Without Config",
|
});
|
||||||
"hidden-without-config",
|
|
||||||
|input| {
|
|
||||||
input.action_required = Some("answer me".to_string());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
|
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
|
||||||
|
|
||||||
|
|
@ -1058,10 +1022,10 @@ mod tests {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
create_ticket(&backend, "Ready Ticket", "ready-ticket", |input| {
|
create_ticket(&backend, "Ready Ticket", |input| {
|
||||||
input.workflow_state = Some(TicketWorkflowState::Ready);
|
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
});
|
});
|
||||||
create_ticket(&backend, "Needs User", "needs-user", |input| {
|
create_ticket(&backend, "Needs User", |input| {
|
||||||
input.workflow_state = Some(TicketWorkflowState::Ready);
|
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
input.attention_required = Some("answer clarification".to_string());
|
input.attention_required = Some("answer clarification".to_string());
|
||||||
});
|
});
|
||||||
|
|
@ -1095,22 +1059,15 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_panel_does_not_infer_workflow_state_from_labels_readiness_or_thread() {
|
fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
create_ticket(
|
create_ticket(&backend, "Readiness Heuristic", |input| {
|
||||||
&backend,
|
input.readiness = Some("implementation-ready".to_string());
|
||||||
"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, "Queued Explicit", "queued-explicit", |input| {
|
create_ticket(&backend, "Queued Words Are Not State", |_| {});
|
||||||
|
create_ticket(&backend, "Queued Explicit", |input| {
|
||||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1120,10 +1077,10 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|row| row.title == "Readiness Heuristic")
|
.find(|row| row.title == "Readiness Heuristic")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let label = model
|
let title = model
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
.find(|row| row.title == "Label Heuristic")
|
.find(|row| row.title == "Queued Words Are Not State")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let queued = model
|
let queued = model
|
||||||
.rows
|
.rows
|
||||||
|
|
@ -1131,11 +1088,20 @@ mod tests {
|
||||||
.find(|row| row.title == "Queued Explicit")
|
.find(|row| row.title == "Queued Explicit")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(readiness.status, "planning");
|
assert_eq!(
|
||||||
|
readiness.ticket.as_ref().unwrap().workflow_state,
|
||||||
|
TicketWorkflowState::Planning
|
||||||
|
);
|
||||||
assert_eq!(readiness.next_action, Some(NextUserAction::Clarify));
|
assert_eq!(readiness.next_action, Some(NextUserAction::Clarify));
|
||||||
assert_eq!(label.status, "planning");
|
assert_eq!(
|
||||||
assert_eq!(label.next_action, Some(NextUserAction::Clarify));
|
title.ticket.as_ref().unwrap().workflow_state,
|
||||||
assert_eq!(queued.status, "queued");
|
TicketWorkflowState::Planning
|
||||||
|
);
|
||||||
|
assert_eq!(title.next_action, Some(NextUserAction::Clarify));
|
||||||
|
assert_eq!(
|
||||||
|
queued.ticket.as_ref().unwrap().workflow_state,
|
||||||
|
TicketWorkflowState::Queued
|
||||||
|
);
|
||||||
assert_eq!(queued.next_action, Some(NextUserAction::Wait));
|
assert_eq!(queued.next_action, Some(NextUserAction::Wait));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1147,22 +1113,21 @@ mod tests {
|
||||||
let ticket_ref = backend
|
let ticket_ref = backend
|
||||||
.create({
|
.create({
|
||||||
let mut input = NewTicket::new("Null Attention Planning");
|
let mut input = NewTicket::new("Null Attention Planning");
|
||||||
input.slug = Some("null-attention-intake".to_string());
|
|
||||||
input.workflow_state = Some(TicketWorkflowState::Planning);
|
input.workflow_state = Some(TicketWorkflowState::Planning);
|
||||||
input
|
input
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let item_path = temp
|
let item_path = temp
|
||||||
.path()
|
.path()
|
||||||
.join(".yoi/tickets/open")
|
.join(".yoi/tickets")
|
||||||
.join(&ticket_ref.id)
|
.join(&ticket_ref.id)
|
||||||
.join("item.md");
|
.join("item.md");
|
||||||
let item = fs::read_to_string(&item_path).unwrap();
|
let item = fs::read_to_string(&item_path).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
&item_path,
|
&item_path,
|
||||||
item.replace(
|
item.replace(
|
||||||
"workflow_state: planning\ncreated_at:",
|
"state: planning\ncreated_at:",
|
||||||
"workflow_state: planning\nattention_required: null\ncreated_at:",
|
"state: planning\nattention_required: null\ncreated_at:",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -1184,8 +1149,8 @@ mod tests {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
create_ticket(&backend, "Plain Backlog", "plain-backlog", |_| {});
|
create_ticket(&backend, "Plain Backlog", |_| {});
|
||||||
create_ticket(&backend, "Done Explicit", "done-explicit", |input| {
|
create_ticket(&backend, "Done Explicit", |input| {
|
||||||
input.workflow_state = Some(TicketWorkflowState::Done);
|
input.workflow_state = Some(TicketWorkflowState::Done);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1213,7 +1178,7 @@ mod tests {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
write_ticket_config(temp.path());
|
write_ticket_config(temp.path());
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
create_ticket(&backend, "Claimed Planning", "claimed-intake", |_| {});
|
create_ticket(&backend, "Claimed Planning", |_| {});
|
||||||
let summary = backend.list(TicketFilter::all()).unwrap().remove(0);
|
let summary = backend.list(TicketFilter::all()).unwrap().remove(0);
|
||||||
let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
|
let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
|
||||||
store
|
store
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ use ticket::config::{
|
||||||
};
|
};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend,
|
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend,
|
||||||
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketReview,
|
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary,
|
||||||
TicketReviewResult, TicketWorkflowState,
|
TicketReview, TicketReviewResult, TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -416,33 +416,52 @@ fn state(
|
||||||
backend: &LocalTicketBackend,
|
backend: &LocalTicketBackend,
|
||||||
options: StateOptions,
|
options: StateOptions,
|
||||||
) -> Result<TicketCliOutput, TicketCliError> {
|
) -> Result<TicketCliOutput, TicketCliError> {
|
||||||
let (from, to) = match options.state {
|
let id = TicketIdOrSlug::Query(options.query.clone());
|
||||||
StateTarget::Planning => (TicketWorkflowState::Planning, TicketWorkflowState::Planning),
|
let target_state = match options.state {
|
||||||
StateTarget::Ready => (TicketWorkflowState::Planning, TicketWorkflowState::Ready),
|
StateTarget::Planning => TicketWorkflowState::Planning,
|
||||||
StateTarget::Queued => (TicketWorkflowState::Ready, TicketWorkflowState::Queued),
|
StateTarget::Ready => TicketWorkflowState::Ready,
|
||||||
StateTarget::InProgress => (TicketWorkflowState::Queued, TicketWorkflowState::InProgress),
|
StateTarget::Queued => TicketWorkflowState::Queued,
|
||||||
StateTarget::Done => (TicketWorkflowState::InProgress, TicketWorkflowState::Done),
|
StateTarget::InProgress => TicketWorkflowState::InProgress,
|
||||||
|
StateTarget::Done => TicketWorkflowState::Done,
|
||||||
StateTarget::Closed => {
|
StateTarget::Closed => {
|
||||||
return Err(TicketCliError::new(
|
return Err(TicketCliError::new(
|
||||||
"yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead",
|
"yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
backend.set_workflow_state(
|
let current = backend.show(id.clone())?;
|
||||||
TicketIdOrSlug::Query(options.query.clone()),
|
let ticket_id = current.meta.id.clone();
|
||||||
ticket::TicketStateChange {
|
match target_state {
|
||||||
from: from.as_str().to_string(),
|
TicketWorkflowState::Ready => backend.mark_intake_ready(
|
||||||
to: to.as_str().to_string(),
|
id,
|
||||||
reason: "cli_state".to_string(),
|
TicketIntakeSummary::new("Marked ready by `yoi ticket state`."),
|
||||||
author: Some("yoi ticket".to_string()),
|
ticket::TicketStateChange {
|
||||||
body: format!("State changed to `{}`.\n", to.as_str()).into(),
|
from: current.meta.workflow_state.as_str().to_string(),
|
||||||
references: Vec::new(),
|
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!(
|
Ok(success(format!(
|
||||||
"state\t{}\t{}\n",
|
"state\t{}\t{}\n",
|
||||||
options.query,
|
ticket_id,
|
||||||
to.as_str()
|
target_state.as_str()
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -811,6 +830,15 @@ mod tests {
|
||||||
run_in_workspace(cli, temp.path()).unwrap()
|
run_in_workspace(cli, temp.path()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn created_id(output: &TicketCliOutput) -> String {
|
||||||
|
output
|
||||||
|
.stdout
|
||||||
|
.strip_prefix("created\t")
|
||||||
|
.and_then(|rest| rest.lines().next())
|
||||||
|
.expect("create output contains created id")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_cli_init_writes_explicit_ticket_config_scaffold() {
|
fn ticket_cli_init_writes_explicit_ticket_config_scaffold() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
@ -862,49 +890,40 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_cli_create_list_show_comment_review_status_close_and_doctor() {
|
fn ticket_cli_create_list_show_comment_review_state_close_and_doctor() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
let created = run(
|
let created = run(&temp, &["create", "--title", "CLI Created"]);
|
||||||
&temp,
|
|
||||||
&[
|
|
||||||
"create",
|
|
||||||
"--title",
|
|
||||||
"CLI Created",
|
|
||||||
"--slug",
|
|
||||||
"cli-created",
|
|
||||||
"--kind",
|
|
||||||
"task",
|
|
||||||
"--priority",
|
|
||||||
"P1",
|
|
||||||
"--label",
|
|
||||||
"ticket,cli",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert_eq!(created.status, TicketCliStatus::Success);
|
assert_eq!(created.status, TicketCliStatus::Success);
|
||||||
assert!(created.stdout.contains("created\t"));
|
assert!(created.stdout.contains("created\t"));
|
||||||
assert!(created.stdout.contains("\tcli-created\topen"));
|
let ticket_id = created_id(&created);
|
||||||
assert!(temp.path().join(".yoi/tickets/open").exists());
|
assert!(temp.path().join(".yoi/tickets").join(&ticket_id).exists());
|
||||||
assert!(!temp.path().join("work-items").exists());
|
assert!(!temp.path().join("work-items").exists());
|
||||||
let created_item = fs::read_to_string(
|
let created_item = fs::read_to_string(
|
||||||
temp.path()
|
temp.path()
|
||||||
.join(".yoi/tickets/open")
|
.join(".yoi/tickets")
|
||||||
.join(created.stdout.split('\t').nth(1).unwrap())
|
.join(&ticket_id)
|
||||||
.join("item.md"),
|
.join("item.md"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(created_item.contains("state:"));
|
||||||
|
assert!(created_item.contains("planning"));
|
||||||
assert!(!created_item.contains("legacy_ticket:"));
|
assert!(!created_item.contains("legacy_ticket:"));
|
||||||
assert!(!created_item.contains("needs_preflight:"));
|
assert!(!created_item.contains("needs_preflight:"));
|
||||||
|
assert!(!created_item.contains("slug:"));
|
||||||
|
assert!(!created_item.contains("workflow_state:"));
|
||||||
|
|
||||||
let listed = run(&temp, &["list", "--status", "open"]);
|
let listed = run(&temp, &["list", "--state", "planning"]);
|
||||||
assert!(listed.stdout.contains("status\tid\tslug"));
|
assert!(listed.stdout.contains("state\tid\ttitle"));
|
||||||
|
assert!(listed.stdout.contains(&ticket_id));
|
||||||
assert!(listed.stdout.contains("CLI Created"));
|
assert!(listed.stdout.contains("CLI Created"));
|
||||||
assert!(!listed.stdout.contains("legacy_ticket"));
|
assert!(!listed.stdout.contains("legacy_ticket"));
|
||||||
assert!(!listed.stdout.contains("needs_preflight"));
|
assert!(!listed.stdout.contains("needs_preflight"));
|
||||||
|
|
||||||
let shown = run(&temp, &["show", "cli-created"]);
|
let shown = run(&temp, &["show", &ticket_id]);
|
||||||
assert!(shown.stdout.contains("# CLI Created"));
|
assert!(shown.stdout.contains("# CLI Created"));
|
||||||
assert!(shown.stdout.contains("Labels: ticket, cli"));
|
assert!(shown.stdout.contains(&format!("ID: {ticket_id}")));
|
||||||
|
assert!(shown.stdout.contains("State: planning"));
|
||||||
assert!(!shown.stdout.contains("legacy_ticket"));
|
assert!(!shown.stdout.contains("legacy_ticket"));
|
||||||
assert!(!shown.stdout.contains("needs_preflight"));
|
assert!(!shown.stdout.contains("needs_preflight"));
|
||||||
|
|
||||||
|
|
@ -912,7 +931,7 @@ mod tests {
|
||||||
&temp,
|
&temp,
|
||||||
&[
|
&[
|
||||||
"comment",
|
"comment",
|
||||||
"cli-created",
|
&ticket_id,
|
||||||
"--role",
|
"--role",
|
||||||
"implementation_report",
|
"implementation_report",
|
||||||
"--message",
|
"--message",
|
||||||
|
|
@ -922,44 +941,62 @@ mod tests {
|
||||||
assert!(
|
assert!(
|
||||||
commented
|
commented
|
||||||
.stdout
|
.stdout
|
||||||
.contains("appended\tcli-created\timplementation_report")
|
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
|
||||||
);
|
);
|
||||||
|
|
||||||
let reviewed = run(
|
let reviewed = run(
|
||||||
&temp,
|
&temp,
|
||||||
&[
|
&[
|
||||||
"review",
|
"review",
|
||||||
"cli-created",
|
&ticket_id,
|
||||||
"--approve",
|
"--approve",
|
||||||
"--message",
|
"--message",
|
||||||
"Looks good.",
|
"Looks good.",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert!(reviewed.stdout.contains("reviewed\tcli-created\tapprove"));
|
assert!(
|
||||||
|
reviewed
|
||||||
|
.stdout
|
||||||
|
.contains(&format!("reviewed\t{}\tapprove", ticket_id))
|
||||||
|
);
|
||||||
|
|
||||||
let pending = run(&temp, &["status", "cli-created", "pending"]);
|
let ready = run(&temp, &["state", &ticket_id, "ready"]);
|
||||||
assert!(pending.stdout.contains("status\tcli-created\tpending"));
|
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
|
||||||
|
let ready_listed = run(&temp, &["list", "--state", "ready"]);
|
||||||
|
assert!(ready_listed.stdout.contains(&ticket_id));
|
||||||
|
|
||||||
|
let queued = run(&temp, &["state", &ticket_id, "queued"]);
|
||||||
|
assert_eq!(queued.stdout, format!("state\t{}\tqueued\n", ticket_id));
|
||||||
|
let queued_listed = run(&temp, &["list", "--state", "queued"]);
|
||||||
|
assert!(queued_listed.stdout.contains(&ticket_id));
|
||||||
|
|
||||||
|
let inprogress = run(&temp, &["state", &ticket_id, "inprogress"]);
|
||||||
|
assert_eq!(
|
||||||
|
inprogress.stdout,
|
||||||
|
format!("state\t{}\tinprogress\n", ticket_id)
|
||||||
|
);
|
||||||
|
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
|
||||||
|
assert!(inprogress_listed.stdout.contains(&ticket_id));
|
||||||
|
|
||||||
|
let done = run(&temp, &["state", &ticket_id, "done"]);
|
||||||
|
assert_eq!(done.stdout, format!("state\t{}\tdone\n", ticket_id));
|
||||||
|
let done_listed = run(&temp, &["list", "--state", "done"]);
|
||||||
|
assert!(done_listed.stdout.contains(&ticket_id));
|
||||||
|
|
||||||
let closed = run(
|
let closed = run(
|
||||||
&temp,
|
&temp,
|
||||||
&[
|
&["close", &ticket_id, "--resolution", "Done via yoi ticket."],
|
||||||
"close",
|
|
||||||
"cli-created",
|
|
||||||
"--resolution",
|
|
||||||
"Done via yoi ticket.",
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
assert!(closed.stdout.contains("closed\tcli-created"));
|
assert!(closed.stdout.contains(&format!("closed\t{}", ticket_id)));
|
||||||
|
|
||||||
let doctor = run(&temp, &["doctor"]);
|
let doctor = run(&temp, &["doctor"]);
|
||||||
assert_eq!(doctor.status, TicketCliStatus::Success);
|
assert_eq!(doctor.status, TicketCliStatus::Success);
|
||||||
assert_eq!(doctor.stdout, "doctor: ok\n");
|
assert_eq!(doctor.stdout, "doctor: ok\n");
|
||||||
|
|
||||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||||
let ticket = backend
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
|
||||||
.show(TicketIdOrSlug::Query("cli-created".to_string()))
|
|
||||||
.unwrap();
|
|
||||||
assert!(ticket.resolution.is_some());
|
assert!(ticket.resolution.is_some());
|
||||||
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed);
|
||||||
assert!(
|
assert!(
|
||||||
ticket
|
ticket
|
||||||
.events
|
.events
|
||||||
|
|
@ -984,28 +1021,11 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
run(
|
let created = run(&temp, &["create", "--title", "Configured Root"]);
|
||||||
&temp,
|
let ticket_id = created_id(&created);
|
||||||
&[
|
|
||||||
"create",
|
|
||||||
"--title",
|
|
||||||
"Configured Root",
|
|
||||||
"--slug",
|
|
||||||
"configured-root",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
assert!(temp.path().join("custom-tickets").join(ticket_id).exists());
|
||||||
temp.path()
|
assert!(!temp.path().join("custom-tickets/open").exists());
|
||||||
.join("custom-tickets/open")
|
|
||||||
.read_dir()
|
|
||||||
.unwrap()
|
|
||||||
.any(|entry| entry
|
|
||||||
.unwrap()
|
|
||||||
.file_name()
|
|
||||||
.to_string_lossy()
|
|
||||||
.contains("configured-root"))
|
|
||||||
);
|
|
||||||
assert!(!temp.path().join("work-items").exists());
|
assert!(!temp.path().join("work-items").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1038,13 +1058,11 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_cli_status_closed_requires_close_command() {
|
fn ticket_cli_state_closed_requires_close_command() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
run(
|
let created = run(&temp, &["create", "--title", "Close Me"]);
|
||||||
&temp,
|
let ticket_id = created_id(&created);
|
||||||
&["create", "--title", "Close Me", "--slug", "close-me"],
|
let cli = parse_ticket_args(&args(&["state", &ticket_id, "closed"])).unwrap();
|
||||||
);
|
|
||||||
let cli = parse_ticket_args(&args(&["status", "close-me", "closed"])).unwrap();
|
|
||||||
let err = run_in_workspace(cli, temp.path()).unwrap_err();
|
let err = run_in_workspace(cli, temp.path()).unwrap_err();
|
||||||
assert!(err.to_string().contains("use `yoi ticket close"));
|
assert!(err.to_string().contains("use `yoi ticket close"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user