ticket: add builtin yoi local backend provider

This commit is contained in:
Keisuke Hirata 2026-06-06 06:19:30 +09:00
parent 8f5a829833
commit cd7e60a8dd
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 {
let workspace = workspace.as_ref();
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 {
backend_root: workspace.join("work-items"),
config_error: Some(error.to_string()),
@ -214,6 +214,7 @@ mod tests {
temp.path(),
r#"
[backend]
provider = "builtin:yoi_local"
root = "tickets"
[roles.coder]
@ -260,6 +261,31 @@ profile = "inherit"
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]
fn does_not_register_ticket_tools_when_root_is_missing() {
let temp = TempDir::new().unwrap();

View File

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

View File

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

View File

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