ticket: add ticket config init scaffold
This commit is contained in:
parent
716eb053d1
commit
f265098eed
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
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,6 +202,9 @@ fn run_command(
|
|||
command: TicketCommand,
|
||||
workspace: &Path,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
match command {
|
||||
TicketCommand::Init => init(workspace),
|
||||
command => {
|
||||
let backend = backend_for_workspace(workspace)?;
|
||||
match command {
|
||||
TicketCommand::Create(options) => create(&backend, options),
|
||||
|
|
@ -195,8 +215,47 @@ fn run_command(
|
|||
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> {
|
||||
let config = TicketConfig::load_workspace(workspace)?;
|
||||
|
|
@ -745,7 +804,7 @@ fn default_author() -> String {
|
|||
}
|
||||
|
||||
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)]
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user