use std::cell::RefCell;
use super::config::StateModel;
use super::ops;
use super::state::{CurrentState, StateMachine};
use crate::commands::ticket::runner::{CommandOutput, CommandRunner};
use crate::commands::ticket::system::GhTicketSystem;
use trusty_mpm::core::trusty_tools_config::ResolvedTicketing;
fn project_model() -> StateModel {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("issue-state.yaml");
let yaml =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let model: StateModel = serde_yaml::from_str(&yaml).expect("repo-root issue-state.yaml parses");
super::validate::validate_model(&model).expect("repo-root issue-state.yaml is valid");
model
}
const EXPECTED_EDGES: &[(&str, &str)] = &[
("open", "status:in-progress"),
("status:in-progress", "status:coded"),
("status:coded", "status:merged"),
("status:merged", "status:tested"),
("status:tested", "closed"),
("status:coded", "closed"),
("status:merged", "closed"),
("status:in-progress", "closed"),
("open", "closed"),
("status:merged", "status:coded"),
("status:in-progress", "open"),
("status:coded", "open"),
("status:merged", "open"),
("status:tested", "open"),
("closed", "open"),
];
#[test]
fn project_model_declares_the_documented_states() {
let m = project_model();
let names: Vec<&str> = m.states.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec![
"open",
"status:in-progress",
"status:coded",
"status:merged",
"status:tested",
"closed",
]
);
let sm = StateMachine::new(&m);
assert_eq!(sm.state_label("open"), None);
assert_eq!(sm.state_label("closed"), None);
assert_eq!(sm.state_label("status:coded"), Some("status:coded"));
assert!(sm.closes_issue("closed"));
assert!(!sm.closes_issue("open"));
}
#[test]
fn project_model_declares_exactly_the_documented_edges() {
let m = project_model();
let mut actual: Vec<(&str, &str)> = m
.transitions
.iter()
.map(|t| (t.from.as_deref().unwrap_or("null"), t.to.as_str()))
.collect();
let mut expected: Vec<(&str, &str)> = EXPECTED_EDGES.to_vec();
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(
actual, expected,
"issue-state.yaml's edge set drifted from the lifecycle in CLAUDE.md"
);
}
#[test]
fn project_model_rejects_skipping_a_rung() {
let m = project_model();
let sm = StateMachine::new(&m);
assert!(!sm.transition_allowed(Some("open"), "status:coded"));
assert!(!sm.transition_allowed(Some("open"), "status:merged"));
assert!(!sm.transition_allowed(Some("status:in-progress"), "status:merged"));
assert!(!sm.transition_allowed(Some("status:coded"), "status:tested"));
}
#[test]
fn project_model_allows_merged_back_to_coded_for_a_followup_fix() {
let m = project_model();
let sm = StateMachine::new(&m);
assert!(
sm.transition_allowed(Some("status:merged"), "status:coded"),
"a merged issue whose live verification failed must be codeable again"
);
assert!(sm.transition_allowed(Some("status:merged"), "status:tested"));
assert!(sm.transition_allowed(Some("status:merged"), "open"));
assert!(!sm.transition_allowed(Some("status:tested"), "status:merged"));
assert!(!sm.transition_allowed(Some("status:coded"), "status:in-progress"));
let mut targets = sm.allowed_targets_from(Some("status:merged"));
targets.sort_unstable();
assert_eq!(
targets,
vec!["closed", "open", "status:coded", "status:tested"]
);
}
#[test]
fn project_model_resolves_an_unlabelled_open_issue_as_open() {
let m = project_model();
let sm = StateMachine::new(&m);
let labels = vec!["bug".to_string(), "P1".to_string()];
assert_eq!(
sm.resolve_current_state(&labels, true),
CurrentState::One("open")
);
}
struct FakeGh {
outputs: RefCell<Vec<CommandOutput>>,
calls: RefCell<Vec<Vec<String>>>,
}
impl FakeGh {
fn new(outputs: Vec<CommandOutput>) -> Self {
Self {
outputs: RefCell::new(outputs),
calls: RefCell::new(Vec::new()),
}
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.borrow().clone()
}
}
impl CommandRunner for FakeGh {
fn run(&self, program: &str, args: &[&str]) -> anyhow::Result<CommandOutput> {
let mut argv = vec![program.to_string()];
argv.extend(args.iter().map(|a| (*a).to_string()));
self.calls.borrow_mut().push(argv);
let mut queued = self.outputs.borrow_mut();
if queued.is_empty() {
return Ok(ok_out(""));
}
Ok(queued.remove(0))
}
}
fn ok_out(stdout: &str) -> CommandOutput {
CommandOutput {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
}
}
fn issue_json(number: u64, labels: &[&str]) -> String {
let labels: Vec<String> = labels
.iter()
.map(|l| format!("{{\"name\":\"{l}\"}}"))
.collect();
format!(
"{{\"number\":{number},\"title\":\"t\",\"body\":\"\",\"state\":\"OPEN\",\
\"labels\":[{}],\"assignees\":[]}}",
labels.join(",")
)
}
#[test]
fn project_transition_coded_to_merged_is_one_gh_edit_with_both_flags() {
let m = project_model();
let gh = FakeGh::new(vec![ok_out(&issue_json(1234, &["status:coded"]))]);
let sys = GhTicketSystem::new(gh);
let report = ops::transition(&sys, &m, 1234, "status:merged", None).expect("transition");
assert_eq!(report.from.as_deref(), Some("status:coded"));
assert_eq!(report.to, "status:merged");
let edits: Vec<Vec<String>> = sys
.runner()
.calls()
.into_iter()
.filter(|c| {
c.get(1).map(String::as_str) == Some("issue")
&& c.get(2).map(String::as_str) == Some("edit")
})
.collect();
assert_eq!(
edits.len(),
1,
"the swap must be ONE gh issue edit: {edits:?}"
);
let edit = &edits[0];
let pos = |flag: &str, value: &str| edit.windows(2).any(|w| w[0] == flag && w[1] == value);
assert!(
pos("--add-label", "status:merged"),
"missing --add-label status:merged in {edit:?}"
);
assert!(
pos("--remove-label", "status:coded"),
"missing --remove-label status:coded in {edit:?}"
);
assert!(
!sys.runner()
.calls()
.iter()
.any(|c| c.get(2).map(String::as_str) == Some("close")),
"merging does not close the issue"
);
}
#[test]
fn project_transition_refuses_two_status_labels_and_names_repair() {
let m = project_model();
let gh = FakeGh::new(vec![ok_out(&issue_json(
1234,
&["status:coded", "status:merged"],
))]);
let sys = GhTicketSystem::new(gh);
let err = ops::transition(&sys, &m, 1234, "status:tested", None)
.unwrap_err()
.to_string();
assert!(err.contains("multiple state labels"), "got: {err}");
assert!(
err.contains("tm issue repair 1234"),
"must name the repair command, got: {err}"
);
let calls = sys.runner().calls();
assert_eq!(calls.len(), 1, "must not mutate on refusal: {calls:?}");
assert_eq!(calls[0].get(2).map(String::as_str), Some("view"));
}
#[test]
fn project_repair_resolves_two_status_labels_to_the_later_one() {
let m = project_model();
let gh = FakeGh::new(vec![ok_out(&issue_json(
1234,
&["status:coded", "status:merged"],
))]);
let sys = GhTicketSystem::new(gh);
let kept = ops::repair(&sys, &m, 1234).expect("repair");
assert_eq!(kept, "status:merged", "keeps the more advanced state");
let removed: Vec<Vec<String>> = sys
.runner()
.calls()
.into_iter()
.filter(|c| c.iter().any(|a| a == "--remove-label"))
.collect();
assert_eq!(removed.len(), 1, "one removal: {removed:?}");
assert!(
removed[0].iter().any(|a| a == "status:coded"),
"must remove the stale label, got {:?}",
removed[0]
);
}
#[test]
fn project_close_requires_evidence_and_then_closes() {
let m = project_model();
let gh = FakeGh::new(vec![ok_out(&issue_json(1234, &["status:tested"]))]);
let sys = GhTicketSystem::new(gh);
let err = ops::transition(&sys, &m, 1234, "closed", None)
.unwrap_err()
.to_string();
assert!(err.contains("requires evidence"), "got: {err}");
assert!(err.contains("--note"), "got: {err}");
assert_eq!(sys.runner().calls().len(), 1, "read only");
let gh = FakeGh::new(vec![ok_out(&issue_json(1234, &["status:tested"]))]);
let sys = GhTicketSystem::new(gh);
ops::transition(&sys, &m, 1234, "closed", Some("tm 0.9.1 live run: OK"))
.expect("transition with evidence");
let verbs: Vec<String> = sys
.runner()
.calls()
.iter()
.filter_map(|c| c.get(2).cloned())
.collect();
assert_eq!(
verbs,
vec!["view", "edit", "comment", "close"],
"got {verbs:?}"
);
let comment = sys
.runner()
.calls()
.into_iter()
.find(|c| c.get(2).map(String::as_str) == Some("comment"))
.expect("comment call");
assert!(
comment.iter().any(|a| a.contains("tm 0.9.1 live run: OK")),
"the evidence must reach the issue, got {comment:?}"
);
}
#[test]
fn project_close_reaches_every_open_state_and_always_needs_a_note_7647() {
let m = project_model();
let sm = StateMachine::new(&m);
for from in [
"open",
"status:in-progress",
"status:coded",
"status:merged",
"status:tested",
] {
assert!(
sm.transition_allowed(Some(from), "closed"),
"{from} → closed must be a legal edge"
);
assert!(
sm.requires_note(Some(from), "closed"),
"{from} → closed must refuse without --note"
);
}
}
#[test]
fn project_close_from_coded_refuses_without_evidence_then_closes_7647() {
let m = project_model();
let gh = FakeGh::new(vec![ok_out(&issue_json(1234, &["status:coded"]))]);
let sys = GhTicketSystem::new(gh);
let err = ops::transition(&sys, &m, 1234, "closed", None)
.unwrap_err()
.to_string();
assert!(err.contains("requires evidence"), "got: {err}");
assert_eq!(sys.runner().calls().len(), 1, "read only");
let gh = FakeGh::new(vec![ok_out(&issue_json(1234, &["status:coded"]))]);
let sys = GhTicketSystem::new(gh);
ops::transition(&sys, &m, 1234, "closed", Some("PR #8050 squash abc1234"))
.expect("close at merge with evidence");
let verbs: Vec<String> = sys
.runner()
.calls()
.iter()
.filter_map(|c| c.get(2).cloned())
.collect();
assert_eq!(
verbs,
vec!["view", "edit", "comment", "close"],
"got {verbs:?}"
);
let edit = sys
.runner()
.calls()
.into_iter()
.find(|c| c.get(2).map(String::as_str) == Some("edit"))
.expect("edit call");
assert!(
edit.windows(2)
.any(|w| w[0] == "--remove-label" && w[1] == "status:coded"),
"the stale lifecycle label must come off, got {edit:?}"
);
}
#[test]
fn project_release_a_claim_removes_the_label_without_closing() {
let m = project_model();
let gh = FakeGh::new(vec![ok_out(&issue_json(1234, &["status:in-progress"]))]);
let sys = GhTicketSystem::new(gh);
let report = ops::transition(&sys, &m, 1234, "open", Some("claim stale")).expect("release");
assert_eq!(report.to, "open");
let verbs: Vec<String> = sys
.runner()
.calls()
.iter()
.filter_map(|c| c.get(2).cloned())
.collect();
assert_eq!(verbs, vec!["view", "edit", "comment"], "got {verbs:?}");
let edit = sys
.runner()
.calls()
.into_iter()
.find(|c| c.get(2).map(String::as_str) == Some("edit"))
.expect("edit call");
assert!(
edit.windows(2)
.any(|w| w[0] == "--remove-label" && w[1] == "status:in-progress"),
"got {edit:?}"
);
assert!(
!edit.iter().any(|a| a == "--add-label"),
"`open` has no label to add, got {edit:?}"
);
}
#[test]
fn project_seed_labels_skips_the_labelless_states() {
let m = project_model();
let gh = FakeGh::new(vec![ok_out("[]")]);
let sys = GhTicketSystem::new(gh);
let report = ops::seed_labels(&sys, &m, &ResolvedTicketing::default(), None, true, &[])
.expect("seed dry-run");
assert_eq!(
report.created,
vec![
"status:in-progress".to_string(),
"status:coded".to_string(),
"status:merged".to_string(),
"status:tested".to_string(),
"trusty-mpm".to_string(),
],
"only the four labelled states, plus the policy set, are seeded"
);
assert!(
report.workstream_skipped,
"no session name was supplied, so the ws/ label is skipped and said so"
);
}
#[test]
fn project_seed_labels_covers_the_whole_harness_label_set() {
let m = project_model();
let gh = FakeGh::new(vec![ok_out("[]")]);
let sys = GhTicketSystem::new(gh);
let report = ops::seed_labels(
&sys,
&m,
&ResolvedTicketing::default(),
Some("tm-tcode-01"),
true,
&[],
)
.expect("seed dry-run");
assert_eq!(
report.created,
vec![
"status:in-progress".to_string(),
"status:coded".to_string(),
"status:merged".to_string(),
"status:tested".to_string(),
"trusty-mpm".to_string(),
"ws/tm-tcode-01".to_string(),
]
);
assert!(!report.workstream_skipped);
}