use trusty_mpm::core::trusty_tools_config::ResolvedTicketing;
use crate::commands::ticket::labels::{AssigneeTarget, RepoLabel};
use crate::commands::ticket::system::TicketSystem;
use super::config::StateModel;
use super::state::{CurrentState, StateMachine};
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct SeedReport {
pub(crate) created: Vec<String>,
pub(crate) already_present: Vec<String>,
pub(crate) dry_run: bool,
pub(crate) workstream_skipped: bool,
}
fn filter_matches(name: &str, filter: &str) -> bool {
if filter.ends_with(':') || filter.ends_with('/') {
name.starts_with(filter)
} else {
name == filter
}
}
fn apply_only_filters(desired: Vec<RepoLabel>, only: &[String]) -> anyhow::Result<Vec<RepoLabel>> {
if only.is_empty() {
return Ok(desired);
}
for filter in only {
if !desired.iter().any(|l| filter_matches(&l.name, filter)) {
let available: Vec<&str> = desired.iter().map(|l| l.name.as_str()).collect();
anyhow::bail!(
"--only `{filter}` matches no label this model would seed; \
available: [{}]",
available.join(", ")
);
}
}
Ok(desired
.into_iter()
.filter(|l| only.iter().any(|f| filter_matches(&l.name, f)))
.collect())
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct TransitionReport {
pub(crate) title: String,
pub(crate) from: Option<String>,
pub(crate) to: String,
pub(crate) assignee_changed: bool,
pub(crate) no_op: bool,
}
fn desired_labels(
model: &StateModel,
ticketing: &ResolvedTicketing,
session_name: Option<&str>,
) -> Vec<RepoLabel> {
let mut out: Vec<RepoLabel> = model
.states
.iter()
.filter_map(|s| s.label.as_ref())
.map(|l| RepoLabel::new(l.name.clone(), l.color.clone(), l.description.clone()))
.collect();
out.extend(
model
.extra_labels
.iter()
.map(|l| RepoLabel::new(l.name.clone(), l.color.clone(), l.description.clone())),
);
for label in trusty_mpm::core::policy_labels::policy_labels_configured(ticketing, session_name)
{
if !out.iter().any(|existing| existing.name == label.name) {
out.push(label);
}
}
out
}
pub(crate) fn seed_labels<S: TicketSystem>(
sys: &S,
model: &StateModel,
ticketing: &ResolvedTicketing,
session_name: Option<&str>,
dry_run: bool,
only: &[String],
) -> anyhow::Result<SeedReport> {
let desired = apply_only_filters(desired_labels(model, ticketing, session_name), only)?;
let existing = sys.list_repo_labels()?;
let existing_names: std::collections::BTreeSet<&str> =
existing.iter().map(|l| l.name.as_str()).collect();
let mut report = SeedReport {
dry_run,
workstream_skipped: only.is_empty() && session_name.is_none_or(|n| n.trim().is_empty()),
..Default::default()
};
for label in desired {
if existing_names.contains(label.name.as_str()) {
report.already_present.push(label.name.clone());
continue;
}
if !dry_run {
sys.create_label(&label)?;
}
report.created.push(label.name.clone());
}
Ok(report)
}
pub(crate) fn transition<S: TicketSystem>(
sys: &S,
model: &StateModel,
issue: u64,
to: &str,
note: Option<&str>,
) -> anyhow::Result<TransitionReport> {
let sm = StateMachine::new(model);
if !sm.is_state(to) {
anyhow::bail!(
"unknown target state `{to}`; valid states: [{}]",
sm.state_names().join(", ")
);
}
let issue_obj = sys.validate(issue)?;
let from: Option<String> = match sm.resolve_current_state(&issue_obj.labels, issue_obj.open) {
CurrentState::One(s) => Some(s.to_string()),
CurrentState::None => None,
CurrentState::Many(states) => {
anyhow::bail!(
"issue #{issue} carries multiple state labels {states:?}; \
run `tm issue repair {issue}` to resolve to a single state first"
);
}
};
if from.as_deref() == Some(to) {
return Ok(TransitionReport {
title: issue_obj.title,
from,
to: to.to_string(),
assignee_changed: false,
no_op: true,
});
}
if !sm.transition_allowed(from.as_deref(), to) {
let from_disp = from.as_deref().unwrap_or("null");
let allowed = sm.allowed_targets_from(from.as_deref());
anyhow::bail!(
"invalid transition {from_disp} → {to}; allowed from {from_disp}: [{}]",
allowed.join(", ")
);
}
let note = note.filter(|n| !n.trim().is_empty());
if sm.requires_note(from.as_deref(), to) && note.is_none() {
let from_disp = from.as_deref().unwrap_or("null");
anyhow::bail!(
"transition {from_disp} → {to} requires evidence; \
re-run with `--note \"<what proves it>\"`"
);
}
let to_label = sm.state_label(to);
let from_label = from.as_deref().and_then(|f| sm.state_label(f));
match (to_label, from_label) {
(Some(add), Some(remove)) => sys.swap_labels(issue, add, remove)?,
(Some(add), None) => sys.add_label(issue, add)?,
(None, Some(remove)) => sys.remove_label(issue, remove)?,
(None, None) => {}
}
let mut assignee_changed = false;
if let Some(target) = sm.assignee_target_for(to) {
let current = if matches!(target, AssigneeTarget::None) {
issue_obj.assignees.clone()
} else {
Vec::new()
};
sys.set_assignee(issue, &target, ¤t)?;
assignee_changed = true;
}
let from_disp = from.as_deref().unwrap_or("(none)");
let mut body = format!("tm issue transition: `{from_disp}` → `{to}`");
if assignee_changed {
body.push_str(" (assignee rule applied)");
}
if let Some(n) = note {
body.push_str("\n\n");
body.push_str(n);
}
sys.comment(issue, &body)?;
if sm.closes_issue(to) {
sys.close_issue(issue)?;
}
Ok(TransitionReport {
title: issue_obj.title,
from,
to: to.to_string(),
assignee_changed,
no_op: false,
})
}
pub(crate) fn current<S: TicketSystem>(
sys: &S,
model: &StateModel,
issue: u64,
) -> anyhow::Result<String> {
let sm = StateMachine::new(model);
let issue_obj = sys.validate(issue)?;
match sm.resolve_current_state(&issue_obj.labels, issue_obj.open) {
CurrentState::One(s) => Ok(s.to_string()),
CurrentState::None => anyhow::bail!(
"issue #{issue} carries no recognised state label; valid states: [{}]",
sm.state_names().join(", ")
),
CurrentState::Many(states) => anyhow::bail!(
"issue #{issue} carries multiple state labels {states:?}; \
run `tm issue repair {issue}` to resolve"
),
}
}
pub(crate) fn repair<S: TicketSystem>(
sys: &S,
model: &StateModel,
issue: u64,
) -> anyhow::Result<String> {
let sm = StateMachine::new(model);
let issue_obj = sys.validate(issue)?;
let present: Vec<&str> = match sm.resolve_current_state(&issue_obj.labels, issue_obj.open) {
CurrentState::One(s) => {
return Ok(s.to_string());
}
CurrentState::None => anyhow::bail!(
"issue #{issue} carries no state label; nothing to repair — \
apply a state with `tm issue transition` instead"
),
CurrentState::Many(states) => states,
};
let keep = present
.iter()
.max_by_key(|name| {
model
.states
.iter()
.find(|s| &s.name == *name)
.and_then(|s| s.order)
.unwrap_or(0)
})
.copied()
.ok_or_else(|| anyhow::anyhow!("internal: empty multi-state set"))?;
for name in present.iter().filter(|n| **n != keep) {
if let Some(label) = sm.state_label(name) {
sys.remove_label(issue, label)?;
}
}
sys.comment(
issue,
&format!("tm issue repair: resolved multiple state labels to `{keep}`"),
)?;
Ok(keep.to_string())
}
#[cfg(test)]
#[path = "ops_tests.rs"]
mod ops_tests;