From f265098eed26c3835acc0acc563d5346cc44eb52 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 7 Jun 2026 12:52:18 +0900 Subject: [PATCH] ticket: add ticket config init scaffold --- crates/client/src/ticket_role.rs | 27 +++++++ crates/ticket/src/config.rs | 57 ++++++++++++++ crates/yoi/src/ticket_cli.rs | 131 ++++++++++++++++++++++++++++--- 3 files changed, 204 insertions(+), 11 deletions(-) diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index 0c599a61..1e5cac32 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -889,6 +889,33 @@ profile = "project:no-such-ticket-role-profile" assert_eq!(plan.profile, "builtin:default"); } + #[test] + fn scaffold_config_allows_intake_and_orchestrator_launch_planning() { + let temp = TempDir::new().unwrap(); + write_config(temp.path(), &ticket::config::ticket_config_scaffold()); + + let intake = plan_ticket_role_launch(TicketRoleLaunchContext::new( + temp.path(), + TicketRole::Intake, + )) + .unwrap(); + assert_eq!(intake.role, TicketRole::Intake); + assert_eq!(intake.profile, "builtin:default"); + assert_eq!(intake.workflow, TicketRole::Intake.default_workflow()); + + let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new( + temp.path(), + TicketRole::Orchestrator, + )) + .unwrap(); + assert_eq!(orchestrator.role, TicketRole::Orchestrator); + assert_eq!(orchestrator.profile, "builtin:default"); + assert_eq!( + orchestrator.workflow, + TicketRole::Orchestrator.default_workflow() + ); + } + #[test] fn spawn_config_still_rejects_inherit_profile_defensively() { let temp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index 652d1f0c..0787a467 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -16,6 +16,33 @@ use thiserror::Error; pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml"; /// Workspace-relative default root for the built-in local Ticket backend. pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets"; +/// Concrete profile selector used by the initial Ticket role scaffold. +pub const TICKET_CONFIG_SCAFFOLD_PROFILE: &str = "builtin:default"; + +/// Return the explicit workspace Ticket config scaffold written by `yoi ticket init`. +/// +/// The scaffold intentionally configures every fixed Ticket role with a concrete +/// profile so strict role launch planning can validate the config without runtime +/// fallback. +pub fn ticket_config_scaffold() -> String { + let mut out = String::from("[backend]\n"); + out.push_str(&format!( + "provider = \"{}\"\n", + TicketBackendProvider::BuiltinYoiLocal.as_str() + )); + out.push_str(&format!( + "root = \"{}\"\n", + DEFAULT_TICKET_BACKEND_RELATIVE_PATH + )); + for role in TicketRole::ALL { + out.push_str(&format!( + "\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", + TICKET_CONFIG_SCAFFOLD_PROFILE, + role.default_workflow() + )); + } + out +} #[derive(Debug, Error)] pub enum TicketConfigError { @@ -646,6 +673,36 @@ workflow = "ticket-orchestrator-routing" ); } + #[test] + fn scaffold_config_includes_backend_and_all_fixed_roles() { + let temp = TempDir::new().unwrap(); + let scaffold = ticket_config_scaffold(); + + assert!(scaffold.contains("[backend]\n")); + assert!(scaffold.contains("provider = \"builtin:yoi_local\"")); + assert!(scaffold.contains("root = \".yoi/tickets\"")); + for role in TicketRole::ALL { + assert!(scaffold.contains(&format!("[roles.{role}]"))); + assert!(scaffold.contains(&format!( + "[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"", + role.default_workflow() + ))); + } + + let config = TicketConfig::from_toml( + temp.path(), + temp.path().join(TICKET_CONFIG_RELATIVE_PATH), + &scaffold, + ) + .unwrap(); + assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); + for role in TicketRole::ALL { + let role_config = config.role_launch_config(role).unwrap(); + assert_eq!(role_config.profile.as_str(), "builtin:default"); + assert_eq!(role_config.workflow.as_str(), role.default_workflow()); + } + } + #[test] fn partial_role_config_keeps_role_defaults() { let temp = TempDir::new().unwrap(); diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 1b830668..b885e183 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -1,8 +1,12 @@ use std::fmt; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; -use ticket::config::TicketConfig; +use ticket::config::{ + DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, + ticket_config_scaffold, +}; use ticket::{ LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketReview, @@ -17,6 +21,7 @@ pub enum TicketCli { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TicketCommand { + Init, Create(CreateOptions), List(ListOptions), Show { query: String }, @@ -129,12 +134,24 @@ impl From for TicketCliError { } } +impl From for TicketCliError { + fn from(error: std::io::Error) -> Self { + Self::new(error.to_string()) + } +} + pub fn parse_ticket_args(args: &[String]) -> Result { if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { return Ok(TicketCli::Help); } let command = match args[0].as_str() { + "init" => { + if args.len() != 1 { + return Err(TicketCliError::new("ticket init takes no arguments")); + } + TicketCommand::Init + } "create" => TicketCommand::Create(parse_create(&args[1..])?), "list" => TicketCommand::List(parse_list(&args[1..])?), "show" => TicketCommand::Show { @@ -185,19 +202,61 @@ fn run_command( command: TicketCommand, workspace: &Path, ) -> Result { - let backend = backend_for_workspace(workspace)?; match command { - TicketCommand::Create(options) => create(&backend, options), - TicketCommand::List(options) => list(&backend, options), - TicketCommand::Show { query } => show(&backend, query), - TicketCommand::Comment(options) => comment(&backend, options), - TicketCommand::Review(options) => review(&backend, options), - TicketCommand::Status(options) => status(&backend, options), - TicketCommand::Close(options) => close(&backend, options), - TicketCommand::Doctor => doctor(&backend), + TicketCommand::Init => init(workspace), + command => { + let backend = backend_for_workspace(workspace)?; + match command { + TicketCommand::Create(options) => create(&backend, options), + TicketCommand::List(options) => list(&backend, options), + TicketCommand::Show { query } => show(&backend, query), + TicketCommand::Comment(options) => comment(&backend, options), + TicketCommand::Review(options) => review(&backend, options), + TicketCommand::Status(options) => status(&backend, options), + TicketCommand::Close(options) => close(&backend, options), + TicketCommand::Doctor => doctor(&backend), + TicketCommand::Init => unreachable!("init handled before backend setup"), + } + } } } +fn init(workspace: &Path) -> Result { + let config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH); + if config_path.exists() { + return Err(TicketCliError::new(format!( + "ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.", + config_path.display() + ))); + } + + let yoi_dir = workspace.join(".yoi"); + fs::create_dir_all(&yoi_dir)?; + let tickets_dir = workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH); + fs::create_dir_all(&tickets_dir)?; + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&config_path) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + TicketCliError::new(format!( + "ticket config already exists at {}; refusing to overwrite. Edit it manually or remove it before running `yoi ticket init`.", + config_path.display() + )) + } else { + TicketCliError::from(error) + } + })?; + file.write_all(ticket_config_scaffold().as_bytes())?; + + Ok(success(format!( + "created\t{}\nensured\t{}\n", + TICKET_CONFIG_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH + ))) +} + fn backend_for_workspace(workspace: &Path) -> Result { let config = TicketConfig::load_workspace(workspace)?; Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())) @@ -745,7 +804,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 Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n" + "yoi ticket\n\nUsage:\n yoi ticket init\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 `yoi ticket init` writes .yoi/ticket.config.toml with explicit fixed role profiles.\n Uses the workspace Ticket config at .yoi/ticket.config.toml when present.\n Supported provider: builtin:yoi_local.\n Without config, the local backend root is <cwd>/.yoi/tickets.\n" } #[cfg(test)] @@ -753,6 +812,7 @@ mod tests { use super::*; use tempfile::TempDir; use ticket::TicketEventKind; + use ticket::config::TicketRole; fn args(items: &[&str]) -> Vec<String> { items.iter().map(|item| item.to_string()).collect() @@ -763,6 +823,54 @@ mod tests { run_in_workspace(cli, temp.path()).unwrap() } + #[test] + fn ticket_cli_init_writes_explicit_ticket_config_scaffold() { + let temp = TempDir::new().unwrap(); + + let initialized = run(&temp, &["init"]); + assert_eq!(initialized.status, TicketCliStatus::Success); + assert!( + initialized + .stdout + .contains("created\t.yoi/ticket.config.toml") + ); + assert!(initialized.stdout.contains("ensured\t.yoi/tickets")); + assert!(temp.path().join(".yoi/tickets").exists()); + + let config = fs::read_to_string(temp.path().join(".yoi/ticket.config.toml")).unwrap(); + assert!(config.contains("[backend]\n")); + assert!(config.contains("provider = \"builtin:yoi_local\"")); + assert!(config.contains("root = \".yoi/tickets\"")); + for role in TicketRole::ALL { + assert!(config.contains(&format!( + "[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"", + role.default_workflow() + ))); + } + } + + #[test] + fn ticket_cli_init_does_not_overwrite_existing_config() { + let temp = TempDir::new().unwrap(); + fs::create_dir_all(temp.path().join(".yoi")).unwrap(); + let config_path = temp.path().join(".yoi/ticket.config.toml"); + fs::write( + &config_path, + "[backend]\nprovider = \"builtin:yoi_local\"\n", + ) + .unwrap(); + + let cli = parse_ticket_args(&args(&["init"])).unwrap(); + let err = run_in_workspace(cli, temp.path()).unwrap_err(); + assert!(err.to_string().contains("already exists")); + assert!(err.to_string().contains("refusing to overwrite")); + assert!(err.to_string().contains("yoi ticket init")); + assert_eq!( + fs::read_to_string(config_path).unwrap(), + "[backend]\nprovider = \"builtin:yoi_local\"\n" + ); + } + #[test] fn ticket_cli_create_list_show_comment_review_status_close_and_doctor() { let temp = TempDir::new().unwrap(); @@ -942,6 +1050,7 @@ mod tests { fn ticket_cli_help_lists_required_commands() { let help = parse_ticket_args(&args(&["--help"])).unwrap(); let output = run_in_workspace(help, Path::new(".")).unwrap(); + assert!(output.stdout.contains("yoi ticket init")); assert!(output.stdout.contains("yoi ticket create")); assert!(output.stdout.contains("yoi ticket doctor")); }