From cd7e60a8dd1175c13d8c562aa13aa7ad0500dadc Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 6 Jun 2026 06:19:30 +0900 Subject: [PATCH] ticket: add builtin yoi local backend provider --- crates/pod/src/feature/builtin/ticket.rs | 28 ++++- crates/ticket/src/config.rs | 150 ++++++++++++++++++++--- crates/yoi/src/ticket_cli.rs | 4 +- docs/development/work-items.md | 9 +- 4 files changed, 169 insertions(+), 22 deletions(-) diff --git a/crates/pod/src/feature/builtin/ticket.rs b/crates/pod/src/feature/builtin/ticket.rs index 0910609c..809d1431 100644 --- a/crates/pod/src/feature/builtin/ticket.rs +++ b/crates/pod/src/feature/builtin/ticket.rs @@ -38,7 +38,7 @@ impl TicketFeature { pub fn for_workspace(workspace: impl AsRef) -> Self { let workspace = workspace.as_ref(); match TicketConfig::load_workspace(workspace) { - Ok(config) => Self::new(config.backend.root), + Ok(config) => Self::new(config.backend_root().to_path_buf()), Err(error) => Self { backend_root: workspace.join("work-items"), config_error: Some(error.to_string()), @@ -214,6 +214,7 @@ mod tests { temp.path(), r#" [backend] +provider = "builtin:yoi_local" root = "tickets" [roles.coder] @@ -260,6 +261,31 @@ profile = "inherit" assert!(message.contains("unknown Ticket role `operator`")); } + #[test] + fn unsupported_ticket_backend_provider_fails_closed() { + let temp = TempDir::new().unwrap(); + make_work_items(&temp.path().join("work-items")); + write_ticket_config( + temp.path(), + r#" +[backend] +provider = "github" +"#, + ); + let mut pending_tools = Vec::new(); + let mut hooks = HookRegistryBuilder::default(); + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature(temp.path())) + .install_into_pending(&mut pending_tools, &mut hooks); + + assert!(pending_tools.is_empty()); + assert!(report.reports[0].installed_tools.is_empty()); + assert_eq!(report.reports[0].diagnostics.len(), 1); + let message = &report.reports[0].diagnostics[0].message; + assert!(message.contains("Ticket tools not registered")); + assert!(message.contains("unsupported Ticket backend provider `github`")); + } + #[test] fn does_not_register_ticket_tools_when_root_is_missing() { let temp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index 8b861bd7..574b2234 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -99,23 +99,44 @@ impl TicketConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketBackendConfig { - pub kind: TicketBackendKind, + pub provider: TicketBackendProvider, pub root: PathBuf, } impl TicketBackendConfig { pub fn default_for_workspace(workspace_root: &Path) -> Self { Self { - kind: TicketBackendKind::Local, + provider: TicketBackendProvider::BuiltinYoiLocal, root: workspace_root.join("work-items"), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TicketBackendKind { - Local, +pub enum TicketBackendProvider { + #[serde(rename = "builtin:yoi_local")] + BuiltinYoiLocal, +} + +impl TicketBackendProvider { + pub fn as_str(self) -> &'static str { + match self { + Self::BuiltinYoiLocal => "builtin:yoi_local", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "builtin:yoi_local" => Some(Self::BuiltinYoiLocal), + _ => None, + } + } +} + +impl fmt::Display for TicketBackendProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -387,7 +408,12 @@ impl RawTicketConfig { roles.inner.insert(role, raw_role.resolve(role)); } Ok(TicketConfig { - backend: self.backend.resolve(workspace_root), + backend: self.backend.resolve(workspace_root).map_err(|message| { + TicketConfigError::Invalid { + path: path.to_path_buf(), + message, + } + })?, roles, }) } @@ -397,18 +423,40 @@ impl RawTicketConfig { #[serde(deny_unknown_fields)] struct RawBackendConfig { #[serde(default)] - kind: Option, + provider: Option, + #[serde(default)] + kind: Option, #[serde(default)] root: Option, } impl RawBackendConfig { - fn resolve(self, workspace_root: &Path) -> TicketBackendConfig { + fn resolve(self, workspace_root: &Path) -> Result { + let provider = match (self.provider, self.kind) { + (Some(provider), None) => TicketBackendProvider::parse(&provider).ok_or_else(|| { + format!( + "unsupported Ticket backend provider `{provider}`; supported provider: `builtin:yoi_local`" + ) + })?, + (None, Some(kind)) if kind == "local" => TicketBackendProvider::BuiltinYoiLocal, + (None, Some(kind)) => { + return Err(format!( + "unsupported legacy Ticket backend kind `{kind}`; use provider = \"builtin:yoi_local\"" + )); + } + (None, None) => TicketBackendProvider::BuiltinYoiLocal, + (Some(_), Some(_)) => { + return Err( + "backend.provider and legacy backend.kind are mutually exclusive; use provider = \"builtin:yoi_local\"" + .to_string(), + ); + } + }; let root = self.root.unwrap_or_else(|| PathBuf::from("work-items")); - TicketBackendConfig { - kind: self.kind.unwrap_or(TicketBackendKind::Local), + Ok(TicketBackendConfig { + provider, root: join_if_relative(workspace_root, &root), - } + }) } } @@ -458,7 +506,10 @@ mod tests { let temp = TempDir::new().unwrap(); let config = TicketConfig::load_workspace(temp.path()).unwrap(); - assert_eq!(config.backend.kind, TicketBackendKind::Local); + assert_eq!( + config.backend.provider, + TicketBackendProvider::BuiltinYoiLocal + ); assert_eq!(config.backend.root, temp.path().join("work-items")); for role in TicketRole::ALL { let role_config = config.role(role); @@ -475,7 +526,7 @@ mod tests { temp.path(), r#" [backend] -kind = "local" +provider = "builtin:yoi_local" root = "custom-work-items" [roles.intake] @@ -506,6 +557,10 @@ workflow = "ticket-orchestrator-routing" ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); + assert_eq!( + config.backend.provider, + TicketBackendProvider::BuiltinYoiLocal + ); assert_eq!(config.backend.root, temp.path().join("custom-work-items")); assert_eq!( config.profile_for(TicketRole::Intake).as_str(), @@ -576,7 +631,47 @@ system_instruction = "$workspace/not-supported" } #[test] - fn unsupported_backend_kind_is_rejected() { + fn legacy_backend_kind_local_is_transitional_alias() { + let temp = TempDir::new().unwrap(); + write_config( + temp.path(), + r#" +[backend] +kind = "local" +root = "legacy-work-items" +"#, + ); + + let config = TicketConfig::load_workspace(temp.path()).unwrap(); + assert_eq!( + config.backend.provider, + TicketBackendProvider::BuiltinYoiLocal + ); + assert_eq!(config.backend_root(), temp.path().join("legacy-work-items")); + } + + #[test] + fn unsupported_backend_provider_is_rejected() { + let temp = TempDir::new().unwrap(); + write_config( + temp.path(), + r#" +[backend] +provider = "github" +"#, + ); + + let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported Ticket backend provider `github`") + ); + assert!(error.to_string().contains("builtin:yoi_local")); + } + + #[test] + fn unsupported_legacy_backend_kind_is_rejected() { let temp = TempDir::new().unwrap(); write_config( temp.path(), @@ -587,8 +682,31 @@ kind = "github" ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); - assert!(error.to_string().contains("unknown variant")); - assert!(error.to_string().contains("github")); + assert!( + error + .to_string() + .contains("unsupported legacy Ticket backend kind `github`") + ); + } + + #[test] + fn backend_provider_and_legacy_kind_are_mutually_exclusive() { + let temp = TempDir::new().unwrap(); + write_config( + temp.path(), + r#" +[backend] +provider = "builtin:yoi_local" +kind = "local" +"#, + ); + + let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); + assert!( + error + .to_string() + .contains("backend.provider and legacy backend.kind are mutually exclusive") + ); } #[test] diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 565a61fe..884131cd 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -745,7 +745,7 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket create --title [--slug <slug>] [--kind <kind>] [--priority P2] [--label a,b]\n yoi ticket list [--status open|pending|closed|all]\n yoi ticket show <id-or-slug>\n yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id-or-slug> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket status <id-or-slug> <open|pending|closed>\n yoi ticket close <id-or-slug> (--resolution <text>|--file <path>)\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Without config, the local backend root is <cwd>/work-items.\n" + "yoi ticket\n\nUsage:\n yoi ticket create --title <title> [--slug <slug>] [--kind <kind>] [--priority P2] [--label a,b]\n yoi ticket list [--status open|pending|closed|all]\n yoi ticket show <id-or-slug>\n yoi ticket comment <id-or-slug> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id-or-slug> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket status <id-or-slug> <open|pending|closed>\n yoi ticket close <id-or-slug> (--resolution <text>|--file <path>)\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the transitional local backend root is <cwd>/work-items.\n" } #[cfg(test)] @@ -867,7 +867,7 @@ mod tests { fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::write( temp.path().join(".yoi/ticket.config.toml"), - "[backend]\nroot = \"custom-work-items\"\n", + "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-work-items\"\n", ) .unwrap(); diff --git a/docs/development/work-items.md b/docs/development/work-items.md index c63035dc..3c88e742 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -58,7 +58,7 @@ MVP shape: ```toml [backend] -kind = "local" +provider = "builtin:yoi_local" root = "work-items" [roles.intake] @@ -103,9 +103,12 @@ This is not an arbitrary role registry. The fixed roles are the roles required b `workflow` is the workflow the launched role should follow. Workflow state and phase-specific prompt injection are future work; any dynamic prompt content must be committed as history before it affects model context. +`provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = "work-items"` is the active transitional root until the planned storage migration moves records under `.yoi/`. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`. + If `.yoi/ticket.config.toml` is missing, defaults are: -- backend: local `<workspace>/work-items` +- backend provider: `builtin:yoi_local` +- backend root: transitional `<workspace>/work-items` - all role profiles: `inherit` - no launch prompt refs - workflows: @@ -276,7 +279,7 @@ Because top-level TUI role launches cannot inherit a parent Profile, configure c # .yoi/ticket.config.toml [backend] -kind = "local" +provider = "builtin:yoi_local" root = "work-items" [roles.intake]