ticket: add workspace ticket config
This commit is contained in:
parent
3edd68558f
commit
767870a4fb
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3632,6 +3632,7 @@ dependencies = [
|
|||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ticket::{LocalTicketBackend, tool::TICKET_TOOL_NAMES, tool::ticket_tools};
|
||||
use ticket::{
|
||||
LocalTicketBackend, config::TicketConfig, tool::TICKET_TOOL_NAMES, tool::ticket_tools,
|
||||
};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||
|
|
@ -22,17 +24,26 @@ const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for t
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct TicketFeature {
|
||||
backend_root: PathBuf,
|
||||
config_error: Option<String>,
|
||||
}
|
||||
|
||||
impl TicketFeature {
|
||||
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
backend_root: backend_root.into(),
|
||||
config_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
|
||||
Self::new(workspace.as_ref().join("work-items"))
|
||||
let workspace = workspace.as_ref();
|
||||
match TicketConfig::load_workspace(workspace) {
|
||||
Ok(config) => Self::new(config.backend.root),
|
||||
Err(error) => Self {
|
||||
backend_root: workspace.join("work-items"),
|
||||
config_error: Some(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backend_root(&self) -> &Path {
|
||||
|
|
@ -80,6 +91,14 @@ impl FeatureModule for TicketFeature {
|
|||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
if let Some(error) = &self.config_error {
|
||||
context
|
||||
.diagnostics()
|
||||
.push(FeatureDiagnostic::warning(format!(
|
||||
"Ticket tools not registered: {error}"
|
||||
)));
|
||||
return Ok(());
|
||||
}
|
||||
let usable_root = match self.usable_backend_root() {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
|
|
@ -142,6 +161,12 @@ mod tests {
|
|||
std::fs::create_dir_all(root.join("closed")).unwrap();
|
||||
}
|
||||
|
||||
fn write_ticket_config(workspace: &Path, content: &str) {
|
||||
let yoi_dir = workspace.join(".yoi");
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
std::fs::write(yoi_dir.join("ticket.config.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_ticket_tools_and_backend_authority() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
@ -182,6 +207,59 @@ mod tests {
|
|||
assert!(report.reports[0].skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_ticket_tools_with_configured_backend_root() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
root = "tickets"
|
||||
|
||||
[roles.coder]
|
||||
profile = "project:coder"
|
||||
"#,
|
||||
);
|
||||
make_work_items(&temp.path().join("tickets"));
|
||||
|
||||
let feature = ticket_tools_feature(temp.path());
|
||||
assert_eq!(feature.backend_root(), temp.path().join("tickets"));
|
||||
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(feature)
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert!(report.reports[0].diagnostics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ticket_config_fails_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
make_work_items(&temp.path().join("work-items"));
|
||||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.operator]
|
||||
profile = "inherit"
|
||||
"#,
|
||||
);
|
||||
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("unknown Ticket role `operator`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_register_ticket_tools_when_root_is_missing() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ schemars = { workspace = true }
|
|||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
toml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
|
|
|||
626
crates/ticket/src/config.rs
Normal file
626
crates/ticket/src/config.rs
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
//! Workspace-local Ticket orchestration configuration.
|
||||
//!
|
||||
//! The config file lives at `.yoi/ticket.config.toml` under a workspace root.
|
||||
//! It intentionally stores lightweight string references for Profile selectors,
|
||||
//! launch prompts, and workflows so this crate remains independent from `pod`
|
||||
//! and `manifest` runtime resolution.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TicketConfigError {
|
||||
#[error("failed to read Ticket config {}: {source}", .path.display())]
|
||||
Read {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to parse Ticket config {}: {source}", .path.display())]
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("invalid Ticket config {}: {message}", .path.display())]
|
||||
Invalid { path: PathBuf, message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketConfig {
|
||||
pub backend: TicketBackendConfig,
|
||||
pub roles: TicketRoleProfiles,
|
||||
}
|
||||
|
||||
impl TicketConfig {
|
||||
pub fn default_for_workspace(workspace_root: impl AsRef<Path>) -> Self {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
Self {
|
||||
backend: TicketBackendConfig::default_for_workspace(workspace_root),
|
||||
roles: TicketRoleProfiles::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_workspace(workspace_root: impl AsRef<Path>) -> Result<Self, TicketConfigError> {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
let path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(content) => content,
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Self::default_for_workspace(workspace_root));
|
||||
}
|
||||
Err(source) => return Err(TicketConfigError::Read { path, source }),
|
||||
};
|
||||
Self::from_toml(workspace_root, &path, &content)
|
||||
}
|
||||
|
||||
pub fn from_toml(
|
||||
workspace_root: impl AsRef<Path>,
|
||||
path: impl AsRef<Path>,
|
||||
content: &str,
|
||||
) -> Result<Self, TicketConfigError> {
|
||||
let workspace_root = workspace_root.as_ref();
|
||||
let path = path.as_ref();
|
||||
let raw: RawTicketConfig =
|
||||
toml::from_str(content).map_err(|source| TicketConfigError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
raw.resolve(workspace_root, path)
|
||||
}
|
||||
|
||||
pub fn backend_root(&self) -> &Path {
|
||||
self.backend.root.as_path()
|
||||
}
|
||||
|
||||
pub fn role(&self, role: TicketRole) -> &TicketRoleConfig {
|
||||
self.roles.get(role)
|
||||
}
|
||||
|
||||
pub fn profile_for(&self, role: TicketRole) -> &ProfileSelectorRef {
|
||||
&self.role(role).profile
|
||||
}
|
||||
|
||||
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
|
||||
self.role(role).launch_prompt.as_ref()
|
||||
}
|
||||
|
||||
pub fn workflow_for(&self, role: TicketRole) -> &WorkflowRef {
|
||||
&self.role(role).workflow
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketBackendConfig {
|
||||
pub kind: TicketBackendKind,
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl TicketBackendConfig {
|
||||
pub fn default_for_workspace(workspace_root: &Path) -> Self {
|
||||
Self {
|
||||
kind: TicketBackendKind::Local,
|
||||
root: workspace_root.join("work-items"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketBackendKind {
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketRole {
|
||||
Intake,
|
||||
Orchestrator,
|
||||
Coder,
|
||||
Reviewer,
|
||||
Investigator,
|
||||
}
|
||||
|
||||
impl TicketRole {
|
||||
pub const ALL: [TicketRole; 5] = [
|
||||
TicketRole::Intake,
|
||||
TicketRole::Orchestrator,
|
||||
TicketRole::Coder,
|
||||
TicketRole::Reviewer,
|
||||
TicketRole::Investigator,
|
||||
];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Intake => "intake",
|
||||
Self::Orchestrator => "orchestrator",
|
||||
Self::Coder => "coder",
|
||||
Self::Reviewer => "reviewer",
|
||||
Self::Investigator => "investigator",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"intake" => Some(Self::Intake),
|
||||
"orchestrator" => Some(Self::Orchestrator),
|
||||
"coder" => Some(Self::Coder),
|
||||
"reviewer" => Some(Self::Reviewer),
|
||||
"investigator" => Some(Self::Investigator),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_workflow(self) -> &'static str {
|
||||
match self {
|
||||
Self::Intake => "ticket-intake-workflow",
|
||||
Self::Orchestrator => "ticket-orchestrator-routing",
|
||||
Self::Coder | Self::Reviewer => "multi-agent-workflow",
|
||||
Self::Investigator => "ticket-orchestrator-routing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TicketRole {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketRoleProfiles {
|
||||
inner: BTreeMap<TicketRole, TicketRoleConfig>,
|
||||
}
|
||||
|
||||
impl TicketRoleProfiles {
|
||||
pub fn get(&self, role: TicketRole) -> &TicketRoleConfig {
|
||||
self.inner
|
||||
.get(&role)
|
||||
.expect("TicketRoleProfiles always contains all fixed roles")
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (TicketRole, &TicketRoleConfig)> {
|
||||
TicketRole::ALL
|
||||
.into_iter()
|
||||
.map(|role| (role, self.get(role)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TicketRoleProfiles {
|
||||
fn default() -> Self {
|
||||
let inner = TicketRole::ALL
|
||||
.into_iter()
|
||||
.map(|role| (role, TicketRoleConfig::default_for_role(role)))
|
||||
.collect();
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketRoleConfig {
|
||||
pub profile: ProfileSelectorRef,
|
||||
pub launch_prompt: Option<PromptRef>,
|
||||
pub workflow: WorkflowRef,
|
||||
}
|
||||
|
||||
impl TicketRoleConfig {
|
||||
pub fn default_for_role(role: TicketRole) -> Self {
|
||||
Self {
|
||||
profile: ProfileSelectorRef::inherit(),
|
||||
launch_prompt: None,
|
||||
workflow: WorkflowRef::from_static(role.default_workflow()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
pub struct ProfileSelectorRef(String);
|
||||
|
||||
impl ProfileSelectorRef {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||
let value = normalized_non_empty(value, "profile selector")?;
|
||||
if value.starts_with("path:")
|
||||
|| value.starts_with('.')
|
||||
|| value.contains('/')
|
||||
|| value.ends_with(".lua")
|
||||
{
|
||||
return Err("profile selector must be `inherit`, `default`, a source-qualified registry selector, or an unqualified registry selector; path selectors are not supported".to_string());
|
||||
}
|
||||
if let Some((source, name)) = value.split_once(':') {
|
||||
if !matches!(source, "builtin" | "user" | "project") {
|
||||
return Err(
|
||||
"profile selector source must be one of `builtin`, `user`, or `project`"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if name.trim().is_empty() {
|
||||
return Err("profile selector registry name must not be empty".to_string());
|
||||
}
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn inherit() -> Self {
|
||||
Self("inherit".to_string())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ProfileSelectorRef {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::new(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileSelectorRef {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for ProfileSelectorRef {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
pub struct PromptRef(String);
|
||||
|
||||
impl PromptRef {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||
normalized_non_empty(value, "launch prompt ref").map(Self)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PromptRef {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::new(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PromptRef {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for PromptRef {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
pub struct WorkflowRef(String);
|
||||
|
||||
impl WorkflowRef {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, String> {
|
||||
normalized_non_empty(value, "workflow ref").map(Self)
|
||||
}
|
||||
|
||||
pub fn from_static(value: &'static str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WorkflowRef {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::new(value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WorkflowRef {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for WorkflowRef {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_non_empty(value: impl Into<String>, label: &str) -> Result<String, String> {
|
||||
let value = value.into();
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
Err(format!("{label} must not be empty"))
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawTicketConfig {
|
||||
#[serde(default)]
|
||||
backend: RawBackendConfig,
|
||||
#[serde(default)]
|
||||
roles: BTreeMap<String, RawTicketRoleConfig>,
|
||||
}
|
||||
|
||||
impl RawTicketConfig {
|
||||
fn resolve(
|
||||
self,
|
||||
workspace_root: &Path,
|
||||
path: &Path,
|
||||
) -> Result<TicketConfig, TicketConfigError> {
|
||||
let mut roles = TicketRoleProfiles::default();
|
||||
for (name, raw_role) in self.roles {
|
||||
let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("unknown Ticket role `{name}`"),
|
||||
})?;
|
||||
roles.inner.insert(role, raw_role.resolve(role));
|
||||
}
|
||||
Ok(TicketConfig {
|
||||
backend: self.backend.resolve(workspace_root),
|
||||
roles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawBackendConfig {
|
||||
#[serde(default)]
|
||||
kind: Option<TicketBackendKind>,
|
||||
#[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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawTicketRoleConfig {
|
||||
profile: ProfileSelectorRef,
|
||||
#[serde(default)]
|
||||
launch_prompt: Option<PromptRef>,
|
||||
#[serde(default)]
|
||||
workflow: Option<WorkflowRef>,
|
||||
}
|
||||
|
||||
impl RawTicketRoleConfig {
|
||||
fn resolve(self, role: TicketRole) -> TicketRoleConfig {
|
||||
TicketRoleConfig {
|
||||
profile: self.profile,
|
||||
launch_prompt: self.launch_prompt,
|
||||
workflow: self
|
||||
.workflow
|
||||
.unwrap_or_else(|| WorkflowRef::from_static(role.default_workflow())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn join_if_relative(base: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
base.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_config(workspace: &Path, content: &str) {
|
||||
let dir = workspace.join(".yoi");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("ticket.config.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_returns_documented_defaults() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let config = TicketConfig::load_workspace(temp.path()).unwrap();
|
||||
|
||||
assert_eq!(config.backend.kind, TicketBackendKind::Local);
|
||||
assert_eq!(config.backend.root, temp.path().join("work-items"));
|
||||
for role in TicketRole::ALL {
|
||||
let role_config = config.role(role);
|
||||
assert_eq!(role_config.profile.as_str(), "inherit");
|
||||
assert!(role_config.launch_prompt.is_none());
|
||||
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_config_parses_fixed_role_refs() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
kind = "local"
|
||||
root = "custom-work-items"
|
||||
|
||||
[roles.intake]
|
||||
profile = "project:intake"
|
||||
launch_prompt = "$workspace/ticket/intake/launch"
|
||||
workflow = "ticket-intake-workflow"
|
||||
|
||||
[roles.orchestrator]
|
||||
profile = "project:orchestrator"
|
||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||
workflow = "ticket-orchestrator-routing"
|
||||
|
||||
[roles.coder]
|
||||
profile = "inherit"
|
||||
launch_prompt = "$workspace/ticket/coder/launch"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
||||
[roles.reviewer]
|
||||
profile = "project:reviewer"
|
||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||
workflow = "multi-agent-workflow"
|
||||
|
||||
[roles.investigator]
|
||||
profile = "default"
|
||||
launch_prompt = "$workspace/ticket/investigator/launch"
|
||||
workflow = "ticket-orchestrator-routing"
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = TicketConfig::load_workspace(temp.path()).unwrap();
|
||||
assert_eq!(config.backend.root, temp.path().join("custom-work-items"));
|
||||
assert_eq!(
|
||||
config.profile_for(TicketRole::Intake).as_str(),
|
||||
"project:intake"
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.launch_prompt_for(TicketRole::Reviewer)
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"$workspace/ticket/reviewer/launch"
|
||||
);
|
||||
assert_eq!(
|
||||
config.workflow_for(TicketRole::Investigator).as_str(),
|
||||
"ticket-orchestrator-routing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_role_config_keeps_role_defaults() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.coder]
|
||||
profile = "project:coder"
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = TicketConfig::load_workspace(temp.path()).unwrap();
|
||||
let coder = config.role(TicketRole::Coder);
|
||||
assert_eq!(coder.profile.as_str(), "project:coder");
|
||||
assert!(coder.launch_prompt.is_none());
|
||||
assert_eq!(coder.workflow.as_str(), "multi-agent-workflow");
|
||||
assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_roles_are_rejected() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.operator]
|
||||
profile = "inherit"
|
||||
"#,
|
||||
);
|
||||
|
||||
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown Ticket role `operator`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_rejected() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.coder]
|
||||
profile = "inherit"
|
||||
system_instruction = "$workspace/not-supported"
|
||||
"#,
|
||||
);
|
||||
|
||||
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown field"));
|
||||
assert!(error.to_string().contains("system_instruction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_backend_kind_is_rejected() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
kind = "github"
|
||||
"#,
|
||||
);
|
||||
|
||||
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown variant"));
|
||||
assert!(error.to_string().contains("github"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_backend_root_resolves_against_workspace() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
root = "nested/work-items"
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = TicketConfig::load_workspace(temp.path()).unwrap();
|
||||
assert_eq!(config.backend_root(), temp.path().join("nested/work-items"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_refs_are_reported() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.coder]
|
||||
profile = "./coder.lua"
|
||||
"#,
|
||||
);
|
||||
|
||||
let error = TicketConfig::load_workspace(temp.path()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("path selectors are not supported")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ use chrono::Utc;
|
|||
use fs4::fs_std::FileExt;
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod config;
|
||||
pub mod tool;
|
||||
|
||||
const STATUSES: [TicketStatus; 3] = [
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-UuKaulbFazTojfaCASnLHYMhmDSgX5LQ0ksJRndq+2w=";
|
||||
cargoHash = "sha256-9/b7tbdMqXbhVpAcCEk+MBoDLhV0M7kIwYn6WUkrD4Y=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user