merge: builtin yoi local backend config

This commit is contained in:
Keisuke Hirata 2026-06-06 06:25:44 +09:00
commit 8c7e94f6fc
No known key found for this signature in database
4 changed files with 169 additions and 22 deletions

View File

@ -38,7 +38,7 @@ impl TicketFeature {
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self { pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
let workspace = workspace.as_ref(); let workspace = workspace.as_ref();
match TicketConfig::load_workspace(workspace) { 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 { Err(error) => Self {
backend_root: workspace.join("work-items"), backend_root: workspace.join("work-items"),
config_error: Some(error.to_string()), config_error: Some(error.to_string()),
@ -214,6 +214,7 @@ mod tests {
temp.path(), temp.path(),
r#" r#"
[backend] [backend]
provider = "builtin:yoi_local"
root = "tickets" root = "tickets"
[roles.coder] [roles.coder]
@ -260,6 +261,31 @@ profile = "inherit"
assert!(message.contains("unknown Ticket role `operator`")); 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] #[test]
fn does_not_register_ticket_tools_when_root_is_missing() { fn does_not_register_ticket_tools_when_root_is_missing() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -99,23 +99,44 @@ impl TicketConfig {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketBackendConfig { pub struct TicketBackendConfig {
pub kind: TicketBackendKind, pub provider: TicketBackendProvider,
pub root: PathBuf, pub root: PathBuf,
} }
impl TicketBackendConfig { impl TicketBackendConfig {
pub fn default_for_workspace(workspace_root: &Path) -> Self { pub fn default_for_workspace(workspace_root: &Path) -> Self {
Self { Self {
kind: TicketBackendKind::Local, provider: TicketBackendProvider::BuiltinYoiLocal,
root: workspace_root.join("work-items"), root: workspace_root.join("work-items"),
} }
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] pub enum TicketBackendProvider {
pub enum TicketBackendKind { #[serde(rename = "builtin:yoi_local")]
Local, BuiltinYoiLocal,
}
impl TicketBackendProvider {
pub fn as_str(self) -> &'static str {
match self {
Self::BuiltinYoiLocal => "builtin:yoi_local",
}
}
pub fn parse(value: &str) -> Option<Self> {
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)] #[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)); roles.inner.insert(role, raw_role.resolve(role));
} }
Ok(TicketConfig { 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, roles,
}) })
} }
@ -397,18 +423,40 @@ impl RawTicketConfig {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RawBackendConfig { struct RawBackendConfig {
#[serde(default)] #[serde(default)]
kind: Option<TicketBackendKind>, provider: Option<String>,
#[serde(default)]
kind: Option<String>,
#[serde(default)] #[serde(default)]
root: Option<PathBuf>, root: Option<PathBuf>,
} }
impl RawBackendConfig { impl RawBackendConfig {
fn resolve(self, workspace_root: &Path) -> TicketBackendConfig { fn resolve(self, workspace_root: &Path) -> Result<TicketBackendConfig, String> {
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")); let root = self.root.unwrap_or_else(|| PathBuf::from("work-items"));
TicketBackendConfig { Ok(TicketBackendConfig {
kind: self.kind.unwrap_or(TicketBackendKind::Local), provider,
root: join_if_relative(workspace_root, &root), root: join_if_relative(workspace_root, &root),
} })
} }
} }
@ -458,7 +506,10 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let config = TicketConfig::load_workspace(temp.path()).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")); assert_eq!(config.backend.root, temp.path().join("work-items"));
for role in TicketRole::ALL { for role in TicketRole::ALL {
let role_config = config.role(role); let role_config = config.role(role);
@ -475,7 +526,7 @@ mod tests {
temp.path(), temp.path(),
r#" r#"
[backend] [backend]
kind = "local" provider = "builtin:yoi_local"
root = "custom-work-items" root = "custom-work-items"
[roles.intake] [roles.intake]
@ -506,6 +557,10 @@ workflow = "ticket-orchestrator-routing"
); );
let config = TicketConfig::load_workspace(temp.path()).unwrap(); 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.backend.root, temp.path().join("custom-work-items"));
assert_eq!( assert_eq!(
config.profile_for(TicketRole::Intake).as_str(), config.profile_for(TicketRole::Intake).as_str(),
@ -576,7 +631,47 @@ system_instruction = "$workspace/not-supported"
} }
#[test] #[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(); let temp = TempDir::new().unwrap();
write_config( write_config(
temp.path(), temp.path(),
@ -587,8 +682,31 @@ kind = "github"
); );
let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
assert!(error.to_string().contains("unknown variant")); assert!(
assert!(error.to_string().contains("github")); 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] #[test]

View File

@ -745,7 +745,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 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)] #[cfg(test)]
@ -867,7 +867,7 @@ mod tests {
fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write( fs::write(
temp.path().join(".yoi/ticket.config.toml"), temp.path().join(".yoi/ticket.config.toml"),
"[backend]\nroot = \"custom-work-items\"\n", "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-work-items\"\n",
) )
.unwrap(); .unwrap();

View File

@ -58,7 +58,7 @@ MVP shape:
```toml ```toml
[backend] [backend]
kind = "local" provider = "builtin:yoi_local"
root = "work-items" root = "work-items"
[roles.intake] [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. `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: 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` - all role profiles: `inherit`
- no launch prompt refs - no launch prompt refs
- workflows: - workflows:
@ -276,7 +279,7 @@ Because top-level TUI role launches cannot inherit a parent Profile, configure c
# .yoi/ticket.config.toml # .yoi/ticket.config.toml
[backend] [backend]
kind = "local" provider = "builtin:yoi_local"
root = "work-items" root = "work-items"
[roles.intake] [roles.intake]