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,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct TransitionReport {
pub(crate) from: Option<String>,
pub(crate) to: String,
pub(crate) assignee_changed: bool,
}
fn declared_labels(model: &StateModel) -> Vec<RepoLabel> {
let mut out: Vec<RepoLabel> = model
.states
.iter()
.map(|s| RepoLabel {
name: s.label.name.clone(),
color: s.label.color.clone(),
description: s.label.description.clone(),
})
.collect();
out.extend(model.extra_labels.iter().map(|l| RepoLabel {
name: l.name.clone(),
color: l.color.clone(),
description: l.description.clone(),
}));
out
}
pub(crate) fn seed_labels<S: TicketSystem>(
sys: &S,
model: &StateModel,
dry_run: bool,
) -> anyhow::Result<SeedReport> {
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,
..Default::default()
};
for label in declared_labels(model) {
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) {
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 !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 to_label = sm
.state_label(to)
.ok_or_else(|| anyhow::anyhow!("internal: state `{to}` has no label"))?;
match from.as_deref().and_then(|f| sm.state_label(f)) {
Some(from_label) => sys.swap_labels(issue, to_label, from_label)?,
None => sys.add_label(issue, to_label)?,
}
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)?;
Ok(TransitionReport {
from,
to: to.to_string(),
assignee_changed,
})
}
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) {
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) {
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;