use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::core::policy_labels::PolicyLabel;
use super::TrustyToolsConfig;
pub const DEFAULT_ASSIGNEE: &str = "@me";
pub const REQUIRED_PR_LINK_KEYWORD: &str = "Refs";
pub const TICKETING_BLOCK_TEMPLATE: &str = "\
# The ticketing standard. Read the resolved values back with `tm issue standard`.
agents:
ticketing:
# #7067: every new issue carries exactly one milestone. An issue filed with
# none needs a `no-milestone: <reason>` comment on it.
milestone_required: true
# #7067: every new issue joins at least one GitHub Project.
project_required: true
# #7067: the Project TITLE new issues join by default. Leave it out to pick
# per issue from the live list `tm issue standard` prints.
# default_project: trusty-mpm
# Whether session launch ensures the component labels exist.
ensure_labels: true
# Whether claiming an issue posts a comment naming the claiming session.
claim_comment: true
# Whether closing requires live-verification evidence.
close_requires_note: true
";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AgentsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticketing: Option<TicketingConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TicketingConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ensure_labels: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_labels: Vec<ConfiguredLabel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_assignee: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claim_comment: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub close_requires_note: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr_link_keyword: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle_model: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub milestone_required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_required: Option<bool>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LabelRole {
#[default]
Component,
Lifecycle,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ConfiguredLabel {
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub color: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default)]
pub role: LabelRole,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TicketingConfigError {
#[error(
"agents.ticketing.pr_link_keyword: `{found}` is not accepted. Fix PRs link with \
`{REQUIRED_PR_LINK_KEYWORD} #N` so a merge never auto-closes an issue before live \
verification, and configuration cannot relax that. A one-off `Closes` is the \
deliberate `tm pr open --closes` flag."
)]
PrLinkKeyword {
found: String,
},
#[error(
"agents.ticketing.extra_labels[{index}].role: `{name}` cannot be a lifecycle label. \
Lifecycle labels are declared in issue-state.yaml and referenced by \
agents.ticketing.lifecycle_model, never embedded here — and `trusty-mpm` in \
particular is a component label only."
)]
LifecycleRole {
index: usize,
name: String,
},
#[error("agents.ticketing.extra_labels[{index}].name: a label name cannot be blank")]
BlankLabelName {
index: usize,
},
#[error("agents.ticketing.default_assignee: cannot be blank (omit the key for `@me`)")]
BlankAssignee,
#[error(
"agents.ticketing.default_project: cannot be blank (omit the key to let the agent pick \
a project from the live list `tm issue standard` prints)"
)]
BlankDefaultProject,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ResolvedTicketing {
pub ensure_labels: bool,
pub default_assignee: String,
pub claim_comment: bool,
pub close_requires_note: bool,
pub pr_link_keyword: &'static str,
pub lifecycle_model: Option<PathBuf>,
pub extra_labels: Vec<PolicyLabel>,
pub default_project: Option<String>,
pub milestone_required: bool,
pub project_required: bool,
}
impl ResolvedTicketing {
#[must_use]
pub fn with_extra_labels(mut self, labels: Vec<PolicyLabel>) -> Self {
self.extra_labels = labels;
self
}
#[must_use]
pub fn with_default_assignee(mut self, assignee: impl Into<String>) -> Self {
self.default_assignee = assignee.into();
self
}
#[must_use]
pub fn with_ensure_labels(mut self, ensure: bool) -> Self {
self.ensure_labels = ensure;
self
}
#[must_use]
pub fn with_default_project(mut self, project: impl Into<String>) -> Self {
self.default_project = Some(project.into());
self
}
#[must_use]
pub fn with_filing_requirements(mut self, milestone: bool, project: bool) -> Self {
self.milestone_required = milestone;
self.project_required = project;
self
}
}
impl Default for ResolvedTicketing {
fn default() -> Self {
Self {
ensure_labels: true,
default_assignee: DEFAULT_ASSIGNEE.to_string(),
claim_comment: true,
close_requires_note: true,
pr_link_keyword: REQUIRED_PR_LINK_KEYWORD,
lifecycle_model: None,
extra_labels: Vec::new(),
default_project: None,
milestone_required: true,
project_required: true,
}
}
}
pub fn resolve_ticketing(
config: &TrustyToolsConfig,
) -> Result<ResolvedTicketing, TicketingConfigError> {
let Some(block) = config.agents.as_ref().and_then(|a| a.ticketing.as_ref()) else {
return Ok(ResolvedTicketing::default());
};
if let Some(keyword) = block.pr_link_keyword.as_deref()
&& !keyword
.trim()
.eq_ignore_ascii_case(REQUIRED_PR_LINK_KEYWORD)
{
return Err(TicketingConfigError::PrLinkKeyword {
found: keyword.to_string(),
});
}
let default_assignee = match block.default_assignee.as_deref() {
Some(a) if a.trim().is_empty() => return Err(TicketingConfigError::BlankAssignee),
Some(a) => a.trim().to_string(),
None => DEFAULT_ASSIGNEE.to_string(),
};
let default_project = match block.default_project.as_deref() {
Some(p) if p.trim().is_empty() => return Err(TicketingConfigError::BlankDefaultProject),
Some(p) => Some(p.trim().to_string()),
None => None,
};
let mut extra_labels = Vec::with_capacity(block.extra_labels.len());
for (index, label) in block.extra_labels.iter().enumerate() {
let name = label.name.trim();
if name.is_empty() {
return Err(TicketingConfigError::BlankLabelName { index });
}
if label.role == LabelRole::Lifecycle {
return Err(TicketingConfigError::LifecycleRole {
index,
name: name.to_string(),
});
}
extra_labels.push(PolicyLabel::new(
name,
label.color.trim(),
label.description.trim(),
));
}
Ok(ResolvedTicketing {
ensure_labels: block.ensure_labels.unwrap_or(true),
default_assignee,
claim_comment: block.claim_comment.unwrap_or(true),
close_requires_note: block.close_requires_note.unwrap_or(true),
pr_link_keyword: REQUIRED_PR_LINK_KEYWORD,
lifecycle_model: block.lifecycle_model.clone(),
extra_labels,
default_project,
milestone_required: block.milestone_required.unwrap_or(true),
project_required: block.project_required.unwrap_or(true),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn config_from(yaml: &str) -> TrustyToolsConfig {
serde_yaml::from_str(yaml).expect("config parses")
}
#[test]
fn absent_block_resolves_to_builtin_defaults() {
let cfg = config_from("auto_resume: true\n");
let resolved = resolve_ticketing(&cfg).expect("resolves");
assert_eq!(resolved, ResolvedTicketing::default());
assert!(resolved.ensure_labels);
assert_eq!(resolved.default_assignee, "@me");
assert_eq!(resolved.pr_link_keyword, "Refs");
assert!(resolved.extra_labels.is_empty());
assert!(resolved.lifecycle_model.is_none());
assert!(resolved.default_project.is_none());
assert!(resolved.milestone_required);
assert!(resolved.project_required);
}
#[test]
fn filing_target_keys_round_trip() {
let cfg = config_from(
"agents:\n\
\x20 ticketing:\n\
\x20 default_project: trusty-mpm\n\
\x20 milestone_required: false\n\
\x20 project_required: false\n",
);
let back = serde_yaml::to_string(&cfg).expect("serialises");
let again: TrustyToolsConfig = serde_yaml::from_str(&back).expect("re-parses");
assert_eq!(cfg, again);
let resolved = resolve_ticketing(&cfg).expect("resolves");
assert_eq!(resolved.default_project.as_deref(), Some("trusty-mpm"));
assert!(!resolved.milestone_required);
assert!(!resolved.project_required);
}
#[test]
fn the_seed_template_parses_to_the_builtin_defaults() {
let cfg = config_from(TICKETING_BLOCK_TEMPLATE);
assert_eq!(
resolve_ticketing(&cfg).expect("the template resolves"),
ResolvedTicketing::default()
);
for key in ["milestone_required", "project_required", "default_project"] {
assert!(TICKETING_BLOCK_TEMPLATE.contains(key), "missing {key}");
}
}
#[test]
fn blank_default_project_is_rejected() {
let cfg = config_from("agents:\n ticketing:\n default_project: \" \"\n");
let err = resolve_ticketing(&cfg).expect_err("blank project title");
assert_eq!(err, TicketingConfigError::BlankDefaultProject);
assert!(
err.to_string().contains("agents.ticketing.default_project"),
"{err}"
);
}
#[test]
fn an_unknown_filing_key_is_dropped_not_fatal() {
let cfg = config_from("agents:\n ticketing:\n default_projects: trusty-mpm\n");
let resolved = resolve_ticketing(&cfg).expect("resolves");
assert!(resolved.default_project.is_none());
}
#[test]
fn agents_config_yaml_round_trip() {
let cfg = config_from(
"agents:\n ticketing:\n ensure_labels: false\n default_assignee: bobmatnyc\n",
);
let back = serde_yaml::to_string(&cfg).expect("serialises");
let again: TrustyToolsConfig = serde_yaml::from_str(&back).expect("re-parses");
assert_eq!(cfg, again);
}
#[test]
fn ticketing_config_yaml_round_trip() {
let cfg = config_from(
"agents:\n\
\x20 ticketing:\n\
\x20 ensure_labels: false\n\
\x20 claim_comment: false\n\
\x20 close_requires_note: false\n\
\x20 default_assignee: bobmatnyc\n\
\x20 pr_link_keyword: refs\n\
\x20 lifecycle_model: /etc/issue-state.yaml\n\
\x20 extra_labels:\n\
\x20 - name: area/cli\n\
\x20 color: 0E8A16\n\
\x20 description: CLI surface\n",
);
let resolved = resolve_ticketing(&cfg).expect("resolves");
assert!(!resolved.ensure_labels);
assert!(!resolved.claim_comment);
assert!(!resolved.close_requires_note);
assert_eq!(resolved.default_assignee, "bobmatnyc");
assert_eq!(resolved.pr_link_keyword, "Refs");
assert_eq!(
resolved.lifecycle_model.as_deref(),
Some(std::path::Path::new("/etc/issue-state.yaml"))
);
assert_eq!(
resolved.extra_labels,
vec![PolicyLabel::new("area/cli", "0E8A16", "CLI surface")]
);
}
#[test]
fn closes_pr_link_keyword_is_rejected() {
let cfg = config_from("agents:\n ticketing:\n pr_link_keyword: Closes\n");
let err = resolve_ticketing(&cfg).expect_err("Closes is refused");
assert_eq!(
err,
TicketingConfigError::PrLinkKeyword {
found: "Closes".to_string()
}
);
assert!(
err.to_string().contains("agents.ticketing.pr_link_keyword"),
"{err}"
);
}
#[test]
fn refs_pr_link_keyword_is_accepted() {
let cfg = config_from("agents:\n ticketing:\n pr_link_keyword: Refs\n");
assert_eq!(
resolve_ticketing(&cfg)
.expect("Refs is the rule")
.pr_link_keyword,
"Refs"
);
}
#[test]
fn lifecycle_role_on_the_convention_label_is_rejected() {
let cfg = config_from(
"agents:\n\
\x20 ticketing:\n\
\x20 extra_labels:\n\
\x20 - name: trusty-mpm\n\
\x20 color: BFD4F2\n\
\x20 role: lifecycle\n",
);
let err = resolve_ticketing(&cfg).expect_err("component-only is the rule");
assert_eq!(
err,
TicketingConfigError::LifecycleRole {
index: 0,
name: "trusty-mpm".to_string()
}
);
assert!(
err.to_string()
.contains("agents.ticketing.extra_labels[0].role"),
"{err}"
);
}
#[test]
fn component_role_is_accepted() {
let cfg = config_from(
"agents:\n ticketing:\n extra_labels:\n - name: trusty-mpm\n \
role: component\n",
);
let resolved = resolve_ticketing(&cfg).expect("component is fine");
assert_eq!(resolved.extra_labels[0].name, "trusty-mpm");
}
#[test]
fn blank_label_name_is_rejected() {
let cfg = config_from("agents:\n ticketing:\n extra_labels:\n - name: \" \"\n");
assert_eq!(
resolve_ticketing(&cfg).expect_err("blank name"),
TicketingConfigError::BlankLabelName { index: 0 }
);
}
#[test]
fn blank_assignee_is_rejected() {
let cfg = config_from("agents:\n ticketing:\n default_assignee: \"\"\n");
assert_eq!(
resolve_ticketing(&cfg).expect_err("blank assignee"),
TicketingConfigError::BlankAssignee
);
}
#[test]
fn extra_labels_extend_the_policy_set() {
let cfg = config_from(
"agents:\n ticketing:\n extra_labels:\n - name: area/cli\n \
color: 0E8A16\n",
);
let resolved = resolve_ticketing(&cfg).expect("resolves");
let names: Vec<String> =
crate::core::policy_labels::policy_labels_configured(&resolved, None)
.into_iter()
.map(|l| l.name)
.collect();
assert!(names.iter().any(|n| n == "trusty-mpm"), "{names:?}");
assert!(names.iter().any(|n| n == "area/cli"), "{names:?}");
}
#[test]
fn an_extra_label_restyles_a_builtin_of_the_same_name() {
let cfg = config_from(
"agents:\n ticketing:\n extra_labels:\n - name: trusty-mpm\n \
color: FF0000\n description: ours\n",
);
let resolved = resolve_ticketing(&cfg).expect("resolves");
let labels = crate::core::policy_labels::policy_labels_configured(&resolved, None);
assert_eq!(labels.len(), 1, "restyle must not duplicate: {labels:?}");
assert_eq!(labels[0].color, "FF0000");
assert_eq!(labels[0].description, "ours");
}
}