migrate: move workspace data to XDG SQLite stores
This commit is contained in:
+36
-4
@@ -1053,6 +1053,12 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
return Ok(Mode::WorkspaceHelp);
|
||||
}
|
||||
if !has_workspace_option(rest) {
|
||||
return Err(ParseError(
|
||||
"yoi workspace serve requires --workspace <PATH>; cwd is no longer forwarded as an implicit workspace"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Mode::WorkspaceServer {
|
||||
subcommand: "serve".to_string(),
|
||||
args: rest.to_vec(),
|
||||
@@ -1065,6 +1071,11 @@ fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_workspace_option(args: &[String]) -> bool {
|
||||
args.iter()
|
||||
.any(|arg| arg == "--workspace" || arg.starts_with("--workspace="))
|
||||
}
|
||||
|
||||
fn run_workspace_server(subcommand: &str, args: Vec<String>) -> ExitCode {
|
||||
let command = match resolve_workspace_server_command() {
|
||||
Ok(command) => command,
|
||||
@@ -1442,13 +1453,13 @@ fn print_help() {
|
||||
println!(
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi workers [--workspace <PATH>] [--workspace-id <ID>] [--backend <URL>] [--runtime-id <ID>]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
|
||||
yoi workspace config <COMMAND> [OPTIONS]
|
||||
yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
yoi workspace serve --workspace <PATH> [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn print_workspace_help() {
|
||||
println!(
|
||||
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an already initialized Workspace\n\nOptions forwarded to init/config diff/serve:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
||||
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace serve --workspace <PATH> [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` requires an explicit workspace path and never forwards cwd as an implicit backend workspace.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an explicitly selected Workspace\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --workspace <PATH> Workspace root (required)\n\nLegacy dev options forwarded to serve:\n --db <PATH> SQLite database path override\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1689,15 +1700,36 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_workspace_serve_passthrough() {
|
||||
match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() {
|
||||
match parse_args_from([
|
||||
"workspace",
|
||||
"serve",
|
||||
"--workspace",
|
||||
"/tmp/ws",
|
||||
"--listen",
|
||||
"127.0.0.1:0",
|
||||
])
|
||||
.unwrap()
|
||||
{
|
||||
Mode::WorkspaceServer { subcommand, args } => {
|
||||
assert_eq!(subcommand, "serve");
|
||||
assert_eq!(args, vec!["--listen", "127.0.0.1:0"]);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["--workspace", "/tmp/ws", "--listen", "127.0.0.1:0"]
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected mode: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_workspace_serve_requires_explicit_workspace() {
|
||||
let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"yoi workspace serve requires --workspace <PATH>; cwd is no longer forwarded as an implicit workspace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_workspace_init_passthrough() {
|
||||
match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() {
|
||||
|
||||
+153
-77
@@ -6,14 +6,14 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use ticket::config::{
|
||||
DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig,
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold,
|
||||
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||
ticket_config_scaffold,
|
||||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
|
||||
TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||
TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary,
|
||||
TicketWorkflowState,
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketReview,
|
||||
TicketReviewResult, TicketSummary, TicketWorkflowState,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||
@@ -30,6 +30,7 @@ pub enum TicketCli {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TicketCommand {
|
||||
Init,
|
||||
ImportLocal,
|
||||
Create(CreateOptions),
|
||||
List(ListOptions),
|
||||
Show { query: String },
|
||||
@@ -179,6 +180,14 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
|
||||
}
|
||||
TicketCommand::Init
|
||||
}
|
||||
"import-local" => {
|
||||
if args.len() != 1 {
|
||||
return Err(TicketCliError::new(
|
||||
"ticket import-local takes no arguments",
|
||||
));
|
||||
}
|
||||
TicketCommand::ImportLocal
|
||||
}
|
||||
"create" => TicketCommand::Create(parse_create(&args[1..])?),
|
||||
"list" => TicketCommand::List(parse_list(&args[1..])?),
|
||||
"show" => TicketCommand::Show {
|
||||
@@ -232,19 +241,22 @@ fn run_command(
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
match command {
|
||||
TicketCommand::Init => init(workspace),
|
||||
TicketCommand::ImportLocal => import_local(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::State(options) => state(&backend, options),
|
||||
TicketCommand::Close(options) => close(&backend, options),
|
||||
TicketCommand::Relation(options) => relation(&backend, options),
|
||||
TicketCommand::Doctor => doctor(&backend),
|
||||
TicketCommand::Init => unreachable!("init handled before backend setup"),
|
||||
TicketCommand::Create(options) => create(backend.as_ref(), options),
|
||||
TicketCommand::List(options) => list(backend.as_ref(), options),
|
||||
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
||||
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
||||
TicketCommand::Review(options) => review(backend.as_ref(), options),
|
||||
TicketCommand::State(options) => state(backend.as_ref(), options),
|
||||
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
||||
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
||||
TicketCommand::Doctor => doctor(backend.as_ref()),
|
||||
TicketCommand::Init | TicketCommand::ImportLocal => {
|
||||
unreachable!("handled before backend setup")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,9 +274,6 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
||||
|
||||
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)?;
|
||||
fs::write(tickets_dir.join(".gitkeep"), b"")?;
|
||||
|
||||
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
|
||||
let scaffold = ticket_config_scaffold();
|
||||
@@ -311,8 +320,8 @@ fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
||||
"updated"
|
||||
};
|
||||
Ok(success(format!(
|
||||
"{verb}\t{}\nensured\t{}\n",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH
|
||||
"{verb}\t{}\nbackend\tworkspace-sqlite\n",
|
||||
WORKSPACE_SETTINGS_RELATIVE_PATH
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -356,14 +365,92 @@ fn workspace_settings_uuid_v7(workspace: &Path) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn backend_for_workspace(workspace: &Path) -> Result<LocalTicketBackend, TicketCliError> {
|
||||
fn backend_for_workspace(workspace: &Path) -> Result<Box<dyn TicketBackend>, TicketCliError> {
|
||||
let config = TicketConfig::load_workspace(workspace)?;
|
||||
Ok(LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
.with_record_language(config.ticket_record_language()))
|
||||
let workspace_id = workspace_id_for_workspace(workspace)?;
|
||||
let db_path = workspace_ticket_database_path(workspace, &workspace_id)?;
|
||||
Ok(Box::new(
|
||||
SqliteTicketBackend::new(db_path, workspace_id)
|
||||
.with_record_language(config.ticket_record_language()),
|
||||
))
|
||||
}
|
||||
|
||||
fn import_local(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let config = TicketConfig::load_workspace(workspace)?;
|
||||
let local = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
.with_record_language(config.ticket_record_language());
|
||||
let workspace_id = workspace_id_for_workspace(workspace)?;
|
||||
let db_path = workspace_ticket_database_path(workspace, &workspace_id)?;
|
||||
let sqlite = SqliteTicketBackend::new(db_path.clone(), workspace_id)
|
||||
.with_record_language(config.ticket_record_language());
|
||||
sqlite.import_from_local_backend(&local)?;
|
||||
Ok(success(format!(
|
||||
"imported\t{}\nbackend\t{}\n",
|
||||
config.backend_root().display(),
|
||||
db_path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn workspace_id_for_workspace(workspace: &Path) -> Result<String, TicketCliError> {
|
||||
let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH);
|
||||
let raw = fs::read_to_string(&settings_path).map_err(|error| {
|
||||
TicketCliError::new(format!(
|
||||
"failed to read workspace settings {}: {error}",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
let value: toml::Value = toml::from_str(&raw).map_err(|error| {
|
||||
TicketCliError::new(format!(
|
||||
"failed to parse workspace settings {}: {error}",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
value
|
||||
.get("workspace_id")
|
||||
.and_then(toml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
TicketCliError::new(format!(
|
||||
"workspace settings {} must contain workspace_id for SQLite Ticket backend",
|
||||
settings_path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_ticket_database_path(
|
||||
workspace: &Path,
|
||||
workspace_id: &str,
|
||||
) -> Result<PathBuf, TicketCliError> {
|
||||
#[cfg(not(test))]
|
||||
let _ = workspace;
|
||||
|
||||
#[cfg(test)]
|
||||
{
|
||||
return Ok(workspace
|
||||
.join(".test-yoi-data")
|
||||
.join("workspace-server")
|
||||
.join(workspace_id)
|
||||
.join("workspace.db"));
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
|
||||
TicketCliError::new(
|
||||
"could not resolve Yoi data directory for SQLite Ticket backend (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
|
||||
)
|
||||
})?;
|
||||
Ok(data_dir
|
||||
.join("workspace-server")
|
||||
.join(workspace_id)
|
||||
.join("workspace.db"))
|
||||
}
|
||||
}
|
||||
|
||||
fn create(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CreateOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let mut input = NewTicket::new(options.title);
|
||||
@@ -374,7 +461,7 @@ fn create(
|
||||
}
|
||||
|
||||
fn list(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: ListOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let filter = match options.state {
|
||||
@@ -410,7 +497,7 @@ fn list(
|
||||
Ok(success(stdout))
|
||||
}
|
||||
|
||||
fn show(backend: &LocalTicketBackend, query: String) -> Result<TicketCliOutput, TicketCliError> {
|
||||
fn show(backend: &dyn TicketBackend, query: String) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let ticket = backend.show(TicketIdOrSlug::Query(query))?;
|
||||
let mut stdout = String::new();
|
||||
stdout.push_str(&format!("# {}\n\n", ticket.meta.title));
|
||||
@@ -545,7 +632,7 @@ fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool {
|
||||
}
|
||||
|
||||
fn comment(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CommentOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let role = options.role.as_str().to_string();
|
||||
@@ -556,7 +643,7 @@ fn comment(
|
||||
}
|
||||
|
||||
fn review(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: ReviewOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let result = options.result.as_str().to_string();
|
||||
@@ -573,7 +660,7 @@ fn review(
|
||||
}
|
||||
|
||||
fn state(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: StateOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let id = TicketIdOrSlug::Query(options.query.clone());
|
||||
@@ -626,7 +713,7 @@ fn state(
|
||||
}
|
||||
|
||||
fn close(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: CloseOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
backend.close(
|
||||
@@ -637,7 +724,7 @@ fn close(
|
||||
}
|
||||
|
||||
fn relation(
|
||||
backend: &LocalTicketBackend,
|
||||
backend: &dyn TicketBackend,
|
||||
options: RelationOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
match options.action {
|
||||
@@ -683,7 +770,7 @@ fn relation(
|
||||
}
|
||||
}
|
||||
|
||||
fn doctor(backend: &LocalTicketBackend) -> Result<TicketCliOutput, TicketCliError> {
|
||||
fn doctor(backend: &dyn TicketBackend) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let report = backend.doctor()?;
|
||||
let mut stdout = String::new();
|
||||
if report.is_ok() {
|
||||
@@ -1166,14 +1253,13 @@ fn default_author() -> String {
|
||||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use ticket::TicketEventKind;
|
||||
use ticket::config::TicketRole;
|
||||
|
||||
fn args(items: &[&str]) -> Vec<String> {
|
||||
@@ -1181,6 +1267,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn run(temp: &TempDir, items: &[&str]) -> TicketCliOutput {
|
||||
if items.first().copied() != Some("init")
|
||||
&& !temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH).exists()
|
||||
{
|
||||
let init_cli = parse_ticket_args(&args(&["init"])).unwrap();
|
||||
let init = run_in_workspace(init_cli, temp.path()).unwrap();
|
||||
assert_eq!(init.status, TicketCliStatus::Success);
|
||||
}
|
||||
let cli = parse_ticket_args(&args(items)).unwrap();
|
||||
run_in_workspace(cli, temp.path()).unwrap()
|
||||
}
|
||||
@@ -1201,9 +1294,8 @@ mod tests {
|
||||
let initialized = run(&temp, &["init"]);
|
||||
assert_eq!(initialized.status, TicketCliStatus::Success);
|
||||
assert!(initialized.stdout.contains("created\t.yoi/workspace.toml"));
|
||||
assert!(initialized.stdout.contains("ensured\t.yoi/tickets"));
|
||||
assert!(temp.path().join(".yoi/tickets").exists());
|
||||
assert!(temp.path().join(".yoi/tickets/.gitkeep").exists());
|
||||
assert!(initialized.stdout.contains("backend\tworkspace-sqlite"));
|
||||
assert!(!temp.path().join(".yoi/tickets").exists());
|
||||
|
||||
let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap();
|
||||
assert!(config.contains("workspace_id = \""));
|
||||
@@ -1299,21 +1391,8 @@ mod tests {
|
||||
assert_eq!(created.status, TicketCliStatus::Success);
|
||||
assert!(created.stdout.contains("created\t"));
|
||||
let ticket_id = created_id(&created);
|
||||
assert!(temp.path().join(".yoi/tickets").join(&ticket_id).exists());
|
||||
assert!(!temp.path().join(".yoi/tickets").exists());
|
||||
assert!(!temp.path().join("work-items").exists());
|
||||
let created_item = fs::read_to_string(
|
||||
temp.path()
|
||||
.join(".yoi/tickets")
|
||||
.join(&ticket_id)
|
||||
.join("item.md"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(created_item.contains("state:"));
|
||||
assert!(created_item.contains("planning"));
|
||||
assert!(!created_item.contains("legacy_ticket:"));
|
||||
assert!(!created_item.contains("needs_preflight:"));
|
||||
assert!(!created_item.contains("slug:"));
|
||||
assert!(!created_item.contains("workflow_state:"));
|
||||
|
||||
let listed = run(&temp, &["list", "--state", "planning"]);
|
||||
assert!(listed.stdout.contains("state\tid\ttitle"));
|
||||
@@ -1395,29 +1474,21 @@ mod tests {
|
||||
assert_eq!(doctor.status, TicketCliStatus::Success);
|
||||
assert_eq!(doctor.stdout, "doctor: ok\n");
|
||||
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
|
||||
assert!(ticket.resolution.is_some());
|
||||
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed);
|
||||
assert!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
||||
);
|
||||
assert!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::Review)
|
||||
);
|
||||
let final_show = run(&temp, &["show", &ticket_id]);
|
||||
assert!(final_show.stdout.contains("State: closed"));
|
||||
assert!(final_show.stdout.contains("Done via yoi ticket."));
|
||||
assert!(final_show.stdout.contains("implementation_report"));
|
||||
assert!(final_show.stdout.contains("review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_show_omits_obsolete_overlay_fields_from_legacy_frontmatter() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let created = run(&temp, &["create", "--title", "Legacy Overlay"]);
|
||||
let ticket_id = created_id(&created);
|
||||
let initialized = run(&temp, &["init"]);
|
||||
assert_eq!(initialized.status, TicketCliStatus::Success);
|
||||
let local = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
let created = local.create(NewTicket::new("Legacy Overlay")).unwrap();
|
||||
let ticket_id = created.id;
|
||||
let item_path = temp
|
||||
.path()
|
||||
.join(".yoi/tickets")
|
||||
@@ -1434,6 +1505,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let imported = run(&temp, &["import-local"]);
|
||||
assert_eq!(imported.status, TicketCliStatus::Success);
|
||||
|
||||
let shown = run(&temp, &["show", &ticket_id]);
|
||||
assert_eq!(shown.status, TicketCliStatus::Success);
|
||||
assert!(shown.stdout.contains("# Legacy Overlay"));
|
||||
@@ -1496,20 +1570,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_uses_configured_backend_root() {
|
||||
fn ticket_cli_import_local_uses_configured_backend_root() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
temp.path().join(".yoi/workspace.toml"),
|
||||
"[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
|
||||
"workspace_id = \"workspace-test\"\n\n[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let local = LocalTicketBackend::new(temp.path().join("custom-tickets"));
|
||||
let created = local.create(NewTicket::new("Configured Root")).unwrap();
|
||||
|
||||
let created = run(&temp, &["create", "--title", "Configured Root"]);
|
||||
let ticket_id = created_id(&created);
|
||||
|
||||
assert!(temp.path().join("custom-tickets").join(ticket_id).exists());
|
||||
assert!(!temp.path().join("custom-tickets/open").exists());
|
||||
let imported = run(&temp, &["import-local"]);
|
||||
assert_eq!(imported.status, TicketCliStatus::Success);
|
||||
let shown = run(&temp, &["show", &created.id]);
|
||||
assert_eq!(shown.status, TicketCliStatus::Success);
|
||||
assert!(shown.stdout.contains("# Configured Root"));
|
||||
assert!(!temp.path().join("work-items").exists());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user