ticket: add ticket config init scaffold

This commit is contained in:
Keisuke Hirata 2026-06-07 12:52:18 +09:00
parent 716eb053d1
commit f265098eed
No known key found for this signature in database
3 changed files with 204 additions and 11 deletions

View File

@ -889,6 +889,33 @@ profile = "project:no-such-ticket-role-profile"
assert_eq!(plan.profile, "builtin:default"); 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] #[test]
fn spawn_config_still_rejects_inherit_profile_defensively() { fn spawn_config_still_rejects_inherit_profile_defensively() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -16,6 +16,33 @@ use thiserror::Error;
pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml"; pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
/// Workspace-relative default root for the built-in local Ticket backend. /// Workspace-relative default root for the built-in local Ticket backend.
pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets"; 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)] #[derive(Debug, Error)]
pub enum TicketConfigError { 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] #[test]
fn partial_role_config_keeps_role_defaults() { fn partial_role_config_keeps_role_defaults() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -1,8 +1,12 @@
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf}; 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::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend,
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketReview, TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketReview,
@ -17,6 +21,7 @@ pub enum TicketCli {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TicketCommand { pub enum TicketCommand {
Init,
Create(CreateOptions), Create(CreateOptions),
List(ListOptions), List(ListOptions),
Show { query: String }, Show { query: String },
@ -129,12 +134,24 @@ impl From<ticket::config::TicketConfigError> for TicketCliError {
} }
} }
impl From<std::io::Error> for TicketCliError {
fn from(error: std::io::Error) -> Self {
Self::new(error.to_string())
}
}
pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> { pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(TicketCli::Help); return Ok(TicketCli::Help);
} }
let command = match args[0].as_str() { 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..])?), "create" => TicketCommand::Create(parse_create(&args[1..])?),
"list" => TicketCommand::List(parse_list(&args[1..])?), "list" => TicketCommand::List(parse_list(&args[1..])?),
"show" => TicketCommand::Show { "show" => TicketCommand::Show {
@ -185,19 +202,61 @@ fn run_command(
command: TicketCommand, command: TicketCommand,
workspace: &Path, workspace: &Path,
) -> Result<TicketCliOutput, TicketCliError> { ) -> Result<TicketCliOutput, TicketCliError> {
let backend = backend_for_workspace(workspace)?;
match command { match command {
TicketCommand::Create(options) => create(&backend, options), TicketCommand::Init => init(workspace),
TicketCommand::List(options) => list(&backend, options), command => {
TicketCommand::Show { query } => show(&backend, query), let backend = backend_for_workspace(workspace)?;
TicketCommand::Comment(options) => comment(&backend, options), match command {
TicketCommand::Review(options) => review(&backend, options), TicketCommand::Create(options) => create(&backend, options),
TicketCommand::Status(options) => status(&backend, options), TicketCommand::List(options) => list(&backend, options),
TicketCommand::Close(options) => close(&backend, options), TicketCommand::Show { query } => show(&backend, query),
TicketCommand::Doctor => doctor(&backend), 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<TicketCliOutput, TicketCliError> {
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<LocalTicketBackend, TicketCliError> { fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
let config = TicketConfig::load_workspace(workspace)?; let config = TicketConfig::load_workspace(workspace)?;
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())) Ok(LocalTicketBackend::new(config.backend_root().to_path_buf()))
@ -745,7 +804,7 @@ fn default_author() -> String {
} }
fn help_text() -> &'static str { fn help_text() -> &'static str {
"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 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)] #[cfg(test)]
@ -753,6 +812,7 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
use ticket::TicketEventKind; use ticket::TicketEventKind;
use ticket::config::TicketRole;
fn args(items: &[&str]) -> Vec<String> { fn args(items: &[&str]) -> Vec<String> {
items.iter().map(|item| item.to_string()).collect() items.iter().map(|item| item.to_string()).collect()
@ -763,6 +823,54 @@ mod tests {
run_in_workspace(cli, temp.path()).unwrap() 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] #[test]
fn ticket_cli_create_list_show_comment_review_status_close_and_doctor() { fn ticket_cli_create_list_show_comment_review_status_close_and_doctor() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -942,6 +1050,7 @@ mod tests {
fn ticket_cli_help_lists_required_commands() { fn ticket_cli_help_lists_required_commands() {
let help = parse_ticket_args(&args(&["--help"])).unwrap(); let help = parse_ticket_args(&args(&["--help"])).unwrap();
let output = run_in_workspace(help, Path::new(".")).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 create"));
assert!(output.stdout.contains("yoi ticket doctor")); assert!(output.stdout.contains("yoi ticket doctor"));
} }