use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use super::audit_rows::{self, REQ_PHASE_LINKAGE, REQ_PHASES_BLOCK};
use super::backend::{
ChildIssue, EpicBackend, FoundTracker, NewIssue, PhaseCandidate, issue_number_from_url,
};
use super::close::{self, CLOSE_COMMENT_PREFIX};
use super::create::{self, CreateOptions, NO_PROJECT_PREFIX};
use super::defer::{self, DeferOptions};
use super::hook;
use super::plan::{self, PlanError};
use super::render::{
self, DEFERRED_END, DEFERRED_START, FOLLOWUPS_END, FOLLOWUPS_START, PHASES_END, PHASES_START,
};
use super::sync;
use crate::commands::issue::config::{DEFAULT_MODEL_YAML, StateModel};
use crate::commands::ticket::runner::{CommandOutput, CommandRunner};
use crate::commands::ticket::system::{Issue, TicketSystem};
use trusty_mpm::core::issue_audit::Verdict;
const REL_PATH: &str = "docs/research/tm-epic-cli/epic-plan.md";
fn status_model() -> StateModel {
let yaml = r#"
version: 1
label_config: { base: "", approved: "", blast_prefix: "", status_prefix: "status:" }
states:
- { name: open, gh_state: open, order: 0 }
- { name: "status:in-progress", label: { name: "status:in-progress", color: "AABBCC" }, order: 1 }
- { name: "status:coded", label: { name: "status:coded", color: "AABBCC" }, order: 2 }
- { name: "status:merged", label: { name: "status:merged", color: "AABBCC" }, order: 3 }
- { name: "status:tested", label: { name: "status:tested", color: "AABBCC" }, order: 4 }
- { name: closed, gh_state: closed, order: 5 }
transitions:
- { from: open, to: "status:in-progress", trigger: executor_start }
- { from: "status:in-progress", to: "status:coded", trigger: executor_start }
- { from: "status:coded", to: closed, trigger: human_label }
assignee_model: { strategy: unchanged, per_state: {} }
"#;
serde_yaml::from_str(yaml).expect("the status model parses")
}
fn prefixless_model() -> StateModel {
let yaml = r#"
version: 1
label_config: { base: "", approved: "", blast_prefix: "", status_prefix: "status:" }
states:
- { name: open, gh_state: open, order: 0 }
- { name: "status:in-progress", label: { name: "in-progress", color: "FBCA04" }, order: 1 }
- { name: "status:coded", label: { name: "status:coded", color: "AABBCC" }, order: 2 }
- { name: closed, gh_state: closed, order: 3 }
transitions:
- { from: open, to: "status:in-progress", trigger: executor_start }
- { from: "status:in-progress", to: "status:coded", trigger: executor_start }
- { from: "status:coded", to: closed, trigger: human_label }
assignee_model: { strategy: unchanged, per_state: {} }
"#;
serde_yaml::from_str(yaml).expect("the prefix-less model parses")
}
const PUBLISHED_SHA: &str = "581cfb4da254f7448c5c042c8d9bea50ebe84828";
const SCOPE_REFUSAL: &str = "error: your authentication token is missing required scopes [project]. \
To add them, run `gh auth refresh -s project`";
const PLAN_DOC: &str = r"# Automate tracker and phase-issue authoring
Prose above the section root, which the parser ignores.
## Epic plan
One paragraph of summary that the tracker body carries.
### Outcomes
- **O1** The first outcome.
- **O2** The second outcome.
### Ratified decisions
| # | Decision |
|---|---|
| D1 | The first decision |
### Ordering
Phase 1 must be used against a real epic before phase 2 is written.
<!-- phases:start -->
| # | Phase | Issue | State | Gate |
|---|-------|-------|-------|------|
| 1 | `first` | TBD | not started | none |
<!-- phases:end -->
### Phase: tm issue epic create|sync
Gate: none — the shape is ratified and on `main`.
What the first phase does.
#### Acceptance criteria
- **AC1** It works.
#### Risk
Multi-issue mutation.
### Phase: defer, close and the transition hook
Gate: phase 1 used live against one real epic.
What the second phase does.
#### Acceptance criteria
- **AC1** It also works.
### Deferred
<!-- deferred:start -->
| Item | Why deferred | Where it went |
|------|--------------|---------------|
| A thing | Later | unscheduled |
<!-- deferred:end -->
## Maintenance
Outside the epic-plan region entirely.
";
#[derive(Debug, Clone, Default)]
struct FakeIssue {
title: String,
body: String,
labels: Vec<String>,
milestone: String,
parent: Option<u64>,
state: String,
comments: Vec<String>,
projects: Vec<u64>,
}
struct FakeBackend {
repo: String,
tracked: bool,
publish: Option<String>,
local: Option<String>,
issues: RefCell<BTreeMap<u64, FakeIssue>>,
next_number: Cell<u64>,
counts: RefCell<HashMap<String, usize>>,
fail: RefCell<Vec<(String, String)>>,
find_labels: RefCell<Vec<String>>,
hidden_from_search: RefCell<BTreeSet<u64>>,
}
impl FakeBackend {
fn new() -> Self {
Self {
repo: "bobmatnyc/trusty-tools".to_string(),
tracked: true,
publish: Some(PUBLISHED_SHA.to_string()),
local: Some("aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee".to_string()),
issues: RefCell::new(BTreeMap::new()),
next_number: Cell::new(100),
counts: RefCell::new(HashMap::new()),
fail: RefCell::new(Vec::new()),
find_labels: RefCell::new(Vec::new()),
hidden_from_search: RefCell::new(BTreeSet::new()),
}
}
fn hide_from_search(&self, number: u64) {
self.hidden_from_search.borrow_mut().insert(number);
}
fn total_calls(&self) -> usize {
self.counts.borrow().values().sum()
}
fn fails(self, key: &str) -> Self {
self.fail
.borrow_mut()
.push((key.to_string(), String::new()));
self
}
fn fails_with(self, key: &str, msg: &str) -> Self {
self.fail
.borrow_mut()
.push((key.to_string(), msg.to_string()));
self
}
fn clear_failures(&self) {
self.fail.borrow_mut().clear();
}
fn tick(&self, op: &str) -> anyhow::Result<()> {
let nth = {
let mut counts = self.counts.borrow_mut();
let n = counts.entry(op.to_string()).or_insert(0);
*n += 1;
*n
};
let fail = self.fail.borrow();
let hit = fail
.iter()
.find(|(k, _)| k == op || k == &format!("{op}:{nth}"));
if let Some((_, msg)) = hit {
if msg.is_empty() {
anyhow::bail!("scripted failure: {op} call {nth}");
}
anyhow::bail!("{msg}");
}
Ok(())
}
fn titles(&self) -> Vec<String> {
self.issues
.borrow()
.values()
.map(|i| i.title.clone())
.collect()
}
fn issue(&self, number: u64) -> FakeIssue {
self.issues
.borrow()
.get(&number)
.cloned()
.unwrap_or_else(|| panic!("no issue #{number} in the fake backend"))
}
fn calls(&self, op: &str) -> usize {
self.counts.borrow().get(op).copied().unwrap_or(0)
}
fn seed_child(&self, tracker: u64, number: u64, title: &str, state: &str, body: &str) {
self.issues.borrow_mut().insert(
number,
FakeIssue {
title: title.to_string(),
body: body.to_string(),
state: state.to_string(),
parent: Some(tracker),
..FakeIssue::default()
},
);
}
fn seed_tracker(&self, number: u64, title: &str, body: &str) {
self.issues.borrow_mut().insert(
number,
FakeIssue {
title: title.to_string(),
body: body.to_string(),
state: "OPEN".to_string(),
..FakeIssue::default()
},
);
}
}
impl EpicBackend for FakeBackend {
fn repo_slug(&self) -> anyhow::Result<String> {
self.tick("repo_slug")?;
Ok(self.repo.clone())
}
fn repo_relative_path(&self, _path: &str) -> anyhow::Result<Option<String>> {
self.tick("repo_relative_path")?;
Ok(self.tracked.then(|| REL_PATH.to_string()))
}
fn publish_sha(&self, _path: &str) -> anyhow::Result<Option<String>> {
self.tick("publish_sha")?;
Ok(self.publish.clone())
}
fn local_sha(&self, _path: &str) -> anyhow::Result<Option<String>> {
self.tick("local_sha")?;
Ok(self.local.clone())
}
fn create_issue(&self, spec: &NewIssue) -> anyhow::Result<u64> {
self.tick("create_issue")?;
let number = self.next_number.get();
self.next_number.set(number + 1);
self.issues.borrow_mut().insert(
number,
FakeIssue {
title: spec.title.clone(),
body: spec.body.clone(),
labels: spec.labels.clone(),
milestone: spec.milestone.clone(),
parent: spec.parent,
state: "OPEN".to_string(),
comments: Vec::new(),
projects: Vec::new(),
},
);
Ok(number)
}
fn set_title(&self, issue: u64, title: &str) -> anyhow::Result<()> {
self.tick("set_title")?;
if let Some(found) = self.issues.borrow_mut().get_mut(&issue) {
found.title = title.to_string();
}
Ok(())
}
fn body(&self, issue: u64) -> anyhow::Result<String> {
self.tick("body")?;
Ok(self.issue(issue).body)
}
fn set_body(&self, issue: u64, body: &str) -> anyhow::Result<()> {
self.tick("set_body")?;
if body.trim().is_empty() {
anyhow::bail!("refusing to write an empty body to #{issue}");
}
if let Some(found) = self.issues.borrow_mut().get_mut(&issue) {
found.body = body.to_string();
}
Ok(())
}
fn children(&self, tracker: u64) -> anyhow::Result<Vec<ChildIssue>> {
self.tick("children")?;
Ok(self
.issues
.borrow()
.iter()
.filter(|(_, i)| i.parent == Some(tracker))
.map(|(n, i)| ChildIssue {
number: *n,
title: i.title.clone(),
state: i.state.clone(),
labels: i.labels.clone(),
body: i.body.clone(),
})
.collect())
}
fn find_tracker(
&self,
outcome: &str,
labels: &[String],
) -> anyhow::Result<Option<FoundTracker>> {
self.tick("find_tracker")?;
self.find_labels.borrow_mut().clone_from(&labels.to_vec());
let placeholder = render::placeholder_tracker_title(outcome);
Ok(self.issues.borrow().iter().find_map(|(n, i)| {
if render::is_tracker_title(&i.title, *n, outcome) {
Some(FoundTracker {
number: *n,
placeholder: false,
})
} else if i.title.trim() == placeholder {
Some(FoundTracker {
number: *n,
placeholder: true,
})
} else {
None
}
}))
}
fn attach_project(&self, repo: &str, issue: u64, number: u64) -> anyhow::Result<()> {
self.tick("attach_project")?;
assert_eq!(repo, self.repo, "the caller resolves the slug once");
if let Some(found) = self.issues.borrow_mut().get_mut(&issue) {
found.projects.push(number);
}
Ok(())
}
fn comments(&self, issue: u64) -> anyhow::Result<Vec<String>> {
self.tick("comments")?;
Ok(self.issue(issue).comments)
}
fn comment(&self, issue: u64, body: &str) -> anyhow::Result<()> {
self.tick("comment")?;
if let Some(found) = self.issues.borrow_mut().get_mut(&issue) {
found.comments.push(body.to_string());
}
Ok(())
}
fn parent(&self, issue: u64) -> anyhow::Result<Option<u64>> {
self.tick("parent")?;
Ok(self.issue(issue).parent)
}
fn close_issue(&self, issue: u64) -> anyhow::Result<()> {
self.tick("close_issue")?;
if let Some(found) = self.issues.borrow_mut().get_mut(&issue) {
found.state = "CLOSED".to_string();
}
Ok(())
}
fn phase_titled_issues(&self, epic: u64) -> anyhow::Result<Vec<PhaseCandidate>> {
self.tick("phase_titled_issues")?;
let hidden = self.hidden_from_search.borrow();
Ok(self
.issues
.borrow()
.iter()
.filter(|(n, _)| !hidden.contains(n))
.filter(|(_, i)| render::epic_number_of(&i.title) == Some(epic))
.map(|(n, i)| PhaseCandidate {
number: *n,
title: i.title.clone(),
})
.collect())
}
}
struct FakeTickets {
issue: Issue,
calls: RefCell<Vec<String>>,
}
impl FakeTickets {
fn new(number: u64, title: &str, labels: &[&str]) -> Self {
Self {
issue: Issue {
number,
title: title.to_string(),
body: String::new(),
labels: labels.iter().map(|l| (*l).to_string()).collect(),
assignees: Vec::new(),
open: true,
},
calls: RefCell::new(Vec::new()),
}
}
fn count(&self, op: &str) -> usize {
self.calls.borrow().iter().filter(|c| *c == op).count()
}
}
impl TicketSystem for FakeTickets {
fn name(&self) -> &'static str {
"fake"
}
fn validate(&self, _issue: u64) -> anyhow::Result<Issue> {
self.calls.borrow_mut().push("validate".to_string());
Ok(self.issue.clone())
}
fn comment(&self, _issue: u64, _body: &str) -> anyhow::Result<()> {
self.calls.borrow_mut().push("comment".to_string());
Ok(())
}
fn add_label(&self, _issue: u64, _label: &str) -> anyhow::Result<()> {
self.calls.borrow_mut().push("add_label".to_string());
Ok(())
}
fn remove_label(&self, _issue: u64, _label: &str) -> anyhow::Result<()> {
self.calls.borrow_mut().push("remove_label".to_string());
Ok(())
}
fn swap_labels(&self, _issue: u64, _add: &str, _remove: &str) -> anyhow::Result<()> {
self.calls.borrow_mut().push("swap_labels".to_string());
Ok(())
}
fn close_issue(&self, _issue: u64) -> anyhow::Result<()> {
self.calls.borrow_mut().push("close_issue".to_string());
Ok(())
}
}
fn opts_for(dir: &tempfile::TempDir, project: Option<u64>) -> CreateOptions {
let path = dir.path().join("epic-plan.md");
std::fs::write(&path, PLAN_DOC).expect("fixture write");
CreateOptions {
plan_path: path,
milestone: "Issue management".to_string(),
components: vec!["trusty-mpm".to_string()],
phase_type: "enhancement".to_string(),
project,
session: "tm-trusty-tools-15".to_string(),
tracker: None,
dry_run: false,
model: status_model(),
}
}
#[test]
fn plan_parses_the_committed_epic_plan() {
let parsed = plan::parse(REL_PATH, PLAN_DOC).expect("the fixture matches the schema");
assert_eq!(parsed.outcome, "Automate tracker and phase-issue authoring");
assert_eq!(parsed.summary.len(), 1, "{:?}", parsed.summary);
assert_eq!(parsed.outcomes.len(), 2, "{:?}", parsed.outcomes);
assert!(parsed.decisions.iter().any(|l| l.contains("D1")));
assert_eq!(parsed.phases.len(), 2);
assert_eq!(parsed.phases[0].title, "tm issue epic create|sync");
assert_eq!(
parsed.phases[0].gate,
"none — the shape is ratified and on `main`."
);
assert!(
parsed.phases[0]
.body
.iter()
.any(|l| l == "## Acceptance criteria"),
"{:?}",
parsed.phases[0].body
);
assert!(
!parsed.phases[0].body.iter().any(|l| l.starts_with("Gate:")),
"the Gate line is lifted out of the body"
);
assert!(parsed.deferred.iter().any(|l| l.contains("A thing")));
}
#[test]
fn plan_refuses_a_document_with_no_epic_plan_heading() {
let err = plan::parse(REL_PATH, "# Title\n\nSome prose.\n").unwrap_err();
assert!(matches!(err, PlanError::MissingHeading { .. }), "{err}");
assert!(err.to_string().contains("## Epic plan"), "{err}");
}
#[test]
fn plan_refuses_an_empty_ordering_section() {
let doc = PLAN_DOC.replace(
"Phase 1 must be used against a real epic before phase 2 is written.",
"",
);
let doc = doc
.lines()
.filter(|l| !l.contains("| 1 | `first` |") && !l.contains("|---|-------|"))
.filter(|l| !l.contains("| # | Phase | Issue | State | Gate |"))
.collect::<Vec<_>>()
.join("\n");
let err = plan::parse(REL_PATH, &doc).unwrap_err();
assert!(matches!(err, PlanError::EmptyOrdering { .. }), "{err}");
assert!(err.to_string().contains("does not need an epic"), "{err}");
}
#[test]
fn plan_refuses_a_missing_ordering_section() {
let doc = PLAN_DOC.replace("### Ordering", "### Sequencing");
let err = plan::parse(REL_PATH, &doc).unwrap_err();
assert!(err.to_string().contains("### Ordering"), "{err}");
}
#[test]
fn plan_refuses_a_phase_with_no_gate_line() {
let doc = PLAN_DOC.replace("Gate: phase 1 used live against one real epic.", "");
let err = plan::parse(REL_PATH, &doc).unwrap_err();
assert!(matches!(err, PlanError::MissingGate { .. }), "{err}");
assert!(
err.to_string()
.contains("defer, close and the transition hook"),
"{err}"
);
}
#[test]
fn plan_refuses_a_phase_with_no_acceptance_criteria() {
let doc = PLAN_DOC.replacen("#### Acceptance criteria", "#### Criteria", 1);
let err = plan::parse(REL_PATH, &doc).unwrap_err();
assert!(matches!(err, PlanError::MissingAcceptance { .. }), "{err}");
}
#[test]
fn plan_refuses_a_document_with_no_phases() {
let doc = PLAN_DOC.replace("### Phase: ", "### Stage: ");
let err = plan::parse(REL_PATH, &doc).unwrap_err();
assert!(matches!(err, PlanError::NoPhases { .. }), "{err}");
}
#[test]
fn plan_strips_the_documents_own_phases_block() {
let parsed = plan::parse(REL_PATH, PLAN_DOC).expect("parses");
let ordering = parsed.ordering.join("\n");
assert!(!ordering.contains(PHASES_START), "{ordering}");
assert!(!ordering.contains("TBD"), "{ordering}");
assert!(ordering.contains("Phase 1 must be used"), "{ordering}");
}
#[test]
fn plan_drops_the_documents_own_deferred_markers() {
let parsed = plan::parse(REL_PATH, PLAN_DOC).expect("parses");
let deferred = parsed.deferred.join("\n");
assert!(!deferred.contains(DEFERRED_START), "{deferred}");
assert!(!deferred.contains(DEFERRED_END), "{deferred}");
assert!(deferred.contains("A thing"), "{deferred}");
}
#[test]
fn plan_refuses_a_document_with_no_h1() {
let no_h1 = PLAN_DOC.replace("# Automate tracker and phase-issue authoring\n", "");
let err = plan::parse(REL_PATH, &no_h1).unwrap_err();
assert!(matches!(err, PlanError::MissingTitle { .. }), "{err}");
assert!(err.to_string().contains("# <outcome>"), "{err}");
let blank_h1 = PLAN_DOC.replace("# Automate tracker and phase-issue authoring", "# ");
assert!(matches!(
plan::parse(REL_PATH, &blank_h1),
Err(PlanError::MissingTitle { .. })
));
}
#[test]
fn plan_refuses_two_phases_with_one_title() {
let doc = PLAN_DOC.replace(
"### Phase: defer, close and the transition hook",
"### Phase: tm issue epic create|sync",
);
let err = plan::parse(REL_PATH, &doc).unwrap_err();
assert!(matches!(err, PlanError::DuplicatePhase { .. }), "{err}");
assert!(err.to_string().contains("matched by title"), "{err}");
}
#[test]
fn plan_refuses_a_phase_with_an_empty_gate_line() {
let doc = PLAN_DOC.replace("Gate: phase 1 used live against one real epic.", "Gate: ");
let err = plan::parse(REL_PATH, &doc).unwrap_err();
assert!(matches!(err, PlanError::EmptyGate { .. }), "{err}");
assert!(
err.to_string()
.contains("defer, close and the transition hook"),
"{err}"
);
}
#[test]
fn plan_ignores_a_heading_inside_a_fenced_block() {
let fenced = concat!(
"### Ordering\n\nPhase 1 before phase 2. For example:\n\n",
"```markdown\n",
"### Phase: an example nobody should file\n\n",
"Gate: made up.\n\n",
"#### Acceptance criteria\n\n- **AC1** invented.\n",
"```\n"
);
let doc = PLAN_DOC.replace(
"### Ordering\n\nPhase 1 must be used against a real epic before phase 2 is written.\n",
fenced,
);
let parsed = plan::parse(REL_PATH, &doc).expect("the fenced heading is prose");
assert_eq!(parsed.phases.len(), 2, "{:?}", parsed.phases);
assert!(
!parsed
.phases
.iter()
.any(|p| p.title.contains("an example nobody should file")),
"{:?}",
parsed.phases
);
assert!(
parsed.ordering.iter().any(|l| l.trim() == "```markdown"),
"{:?}",
parsed.ordering
);
}
#[test]
fn plan_ignores_a_gate_line_inside_a_fenced_block() {
let doc = PLAN_DOC.replace(
"Gate: phase 1 used live against one real epic.",
"```\nGate: quoted, not declared.\n```",
);
let err = plan::parse(REL_PATH, &doc).unwrap_err();
assert!(matches!(err, PlanError::MissingGate { .. }), "{err}");
let parsed = plan::parse(REL_PATH, PLAN_DOC).expect("parses");
assert!(
!parsed.phases[0].body.iter().any(|l| l.starts_with("Gate:")),
"{:?}",
parsed.phases[0].body
);
}
#[test]
fn tracker_title_carries_the_number() {
assert_eq!(
render::placeholder_tracker_title("An outcome"),
"[EPIC] An outcome"
);
assert_eq!(
render::tracker_title(8445, "An outcome"),
"[EPIC 8445] An outcome"
);
}
#[test]
fn phase_title_carries_both_numbers() {
assert_eq!(
render::phase_title(8445, 2, "do the thing"),
"[EPIC_8445 PHASE_2] do the thing"
);
assert_eq!(
render::phase_what("[EPIC_8445 PHASE_2] do the thing"),
"do the thing"
);
}
#[test]
fn a_tracker_title_is_recognised_by_its_own_number() {
assert!(render::is_tracker_title(
"[EPIC 8445] An outcome",
8445,
"An outcome"
));
assert!(!render::is_tracker_title(
"[EPIC 8445] An outcome",
8446,
"An outcome"
));
assert!(!render::is_tracker_title("An outcome", 8445, "An outcome"));
}
#[test]
fn next_phase_number_never_reuses_a_deleted_number() {
let gapped = vec![child(1, 101), child(2, 102), child(6, 106)];
assert_eq!(render::next_phase_number(&gapped), 7);
let after = vec![child(1, 101), child(2, 102), child(7, 107)];
assert_eq!(render::next_phase_number(&after), 8);
assert_eq!(render::next_phase_number(&[]), 1);
let stray = vec![ChildIssue {
number: 200,
title: "a follow-up".to_string(),
state: "OPEN".to_string(),
labels: Vec::new(),
body: String::new(),
}];
assert_eq!(render::next_phase_number(&stray), 1);
}
fn child(phase: u64, number: u64) -> ChildIssue {
ChildIssue {
number,
title: render::phase_title(8445, phase, &format!("phase {phase}")),
state: "OPEN".to_string(),
labels: Vec::new(),
body: format!("## Gate\n\ngate {phase}\n"),
}
}
#[test]
fn render_replaces_the_whole_phases_block() {
let body = format!("prose\n\n{PHASES_START}\n| stale |\n{PHASES_END}\n\ntail\n");
let table = render::phases_table(&[child(1, 101), child(2, 102)], &status_model());
let out = render::replace_block(&body, PHASES_START, PHASES_END, &table).expect("replaces");
assert!(!out.contains("| stale |"), "{out}");
assert!(
out.contains("| 1 | phase 1 | #101 | open | gate 1 |"),
"{out}"
);
assert!(
out.contains("| 2 | phase 2 | #102 | open | gate 2 |"),
"{out}"
);
assert!(out.starts_with("prose\n"), "{out}");
assert!(out.ends_with("\ntail\n"), "{out}");
}
#[test]
fn gate_of_reads_only_the_first_paragraph() {
let body =
"Part of #1.\n\n## Gate\n\nthe gate line.\n\nthe phase summary.\n\n## Risk\n\nnope\n";
let rendered = render::phases_table(
&[ChildIssue {
number: 7,
title: render::phase_title(1, 1, "x"),
state: "CLOSED".to_string(),
labels: Vec::new(),
body: body.to_string(),
}],
&status_model(),
);
assert!(rendered.contains("| the gate line. |"), "{rendered}");
assert!(!rendered.contains("phase summary"), "{rendered}");
assert!(rendered.contains("| closed |"), "{rendered}");
}
#[test]
fn render_escapes_a_pipe_in_a_phase_title() {
let rendered = render::phases_table(
&[ChildIssue {
number: 9,
title: "[EPIC_1 PHASE_1] tm issue epic create|sync".to_string(),
state: "OPEN".to_string(),
labels: Vec::new(),
body: "## Gate\n\nnone | really\n".to_string(),
}],
&status_model(),
);
assert!(rendered.contains(r"create\|sync"), "{rendered}");
assert!(rendered.contains(r"none \| really"), "{rendered}");
let row = rendered.lines().next_back().expect("a row");
assert_eq!(row.matches(r"\|").count(), 2, "{row}");
}
#[test]
fn render_refuses_a_body_with_no_markers() {
let err = render::replace_block("just prose\n", PHASES_START, PHASES_END, "x").unwrap_err();
assert!(err.to_string().contains(PHASES_START), "{err}");
}
#[test]
fn render_refuses_a_body_with_two_start_markers() {
let body = format!("{PHASES_START}\na\n{PHASES_START}\nb\n{PHASES_END}\n");
let err = render::replace_block(&body, PHASES_START, PHASES_END, "x").unwrap_err();
assert!(err.to_string().contains("exactly one is required"), "{err}");
}
#[test]
fn render_refuses_an_inverted_marker_pair() {
let body = format!("{PHASES_END}\na\n{PHASES_START}\n");
let err = render::replace_block(&body, PHASES_START, PHASES_END, "x").unwrap_err();
assert!(err.to_string().contains("precedes"), "{err}");
}
#[test]
fn an_empty_replacement_leaves_the_markers_adjacent_and_never_wipes_the_body() {
let body = format!("prose\n{PHASES_START}\nold\n{PHASES_END}\n");
let out = render::replace_block(&body, PHASES_START, PHASES_END, "").expect("empty rows");
assert_eq!(out, format!("prose\n{PHASES_START}\n{PHASES_END}\n"));
assert!(
out.contains(PHASES_START) && out.contains(PHASES_END),
"{out}"
);
}
#[test]
fn tracker_body_links_the_plan_by_sha() {
let parsed = plan::parse(REL_PATH, PLAN_DOC).expect("parses");
let url = render::plan_permalink("bobmatnyc/trusty-tools", PUBLISHED_SHA, REL_PATH);
let body = render::tracker_body(&parsed, &url);
let sha_link = regex::Regex::new(r"/blob/[0-9a-f]{40}/").expect("static regex");
assert!(sha_link.is_match(&body), "{body}");
assert!(!body.contains("/blob/main/"), "{body}");
assert!(body.contains(REL_PATH), "{body}");
}
#[test]
fn tracker_body_carries_all_three_marker_blocks() {
let parsed = plan::parse(REL_PATH, PLAN_DOC).expect("parses");
let body = render::tracker_body(&parsed, "https://example.invalid/plan.md");
for marker in [
PHASES_START,
PHASES_END,
DEFERRED_START,
DEFERRED_END,
FOLLOWUPS_START,
FOLLOWUPS_END,
] {
assert_eq!(
body.lines().filter(|l| l.trim() == marker).count(),
1,
"expected exactly one {marker} in:\n{body}"
);
}
assert!(body.contains("## Outcomes"), "{body}");
assert!(body.contains("**O1**"), "{body}");
assert!(body.contains("## Ordering"), "{body}");
}
#[test]
fn phase_body_declares_its_gate_and_its_parent() {
let parsed = plan::parse(REL_PATH, PLAN_DOC).expect("parses");
let body = render::phase_body(&parsed.phases[1], 8445, 2, 2);
assert!(body.starts_with("Part of #8445, phase 2 of 2."), "{body}");
assert!(body.contains("## Gate\n\nphase 1 used live"), "{body}");
assert!(body.contains("## Acceptance criteria"), "{body}");
}
#[test]
fn create_files_a_tracker_then_its_phases() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new();
let report = create::create(&backend, &opts_for(&dir, None)).expect("creates");
let tracker = report.tracker.expect("a tracker");
assert_eq!(tracker, 100);
assert_eq!(report.filed, vec![(1, 101), (2, 102)]);
assert!(report.skipped.is_empty());
assert_eq!(
backend.issue(tracker).title,
"[EPIC 100] Automate tracker and phase-issue authoring"
);
assert_eq!(
backend.issue(101).title,
"[EPIC_100 PHASE_1] tm issue epic create|sync"
);
assert_eq!(backend.issue(101).parent, Some(tracker));
assert_eq!(backend.issue(102).parent, Some(tracker));
let body = backend.issue(tracker).body;
assert!(
body.contains("| 1 | tm issue epic create\\|sync | #101 |"),
"{body}"
);
assert!(
body.contains("| 2 | defer, close and the transition hook | #102 |"),
"{body}"
);
}
#[test]
fn create_never_leaves_a_placeholder_title_when_a_phase_fails() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new().fails("create_issue:3");
let err = create::create(&backend, &opts_for(&dir, None)).unwrap_err();
assert!(err.to_string().contains("scripted failure"), "{err}");
let titles = backend.titles();
assert_eq!(titles.len(), 2, "{titles:?}");
for title in &titles {
assert!(
!title.starts_with("[EPIC] "),
"a placeholder title survived the interruption: {titles:?}"
);
assert!(!title.contains("PHASE_<"), "{titles:?}");
}
assert!(
titles.iter().any(|t| t.starts_with("[EPIC 100] ")),
"{titles:?}"
);
assert!(
titles.iter().any(|t| t.starts_with("[EPIC_100 PHASE_1] ")),
"{titles:?}"
);
}
#[test]
fn create_skips_a_phase_that_already_exists() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new().fails("create_issue:3");
let opts = opts_for(&dir, None);
create::create(&backend, &opts).expect_err("the second phase fails");
backend.clear_failures();
let report = create::create(&backend, &opts).expect("the re-run completes");
assert_eq!(
report.tracker,
Some(100),
"the tracker is adopted, not re-filed"
);
assert_eq!(
report.skipped,
vec!["tm issue epic create|sync".to_string()]
);
assert_eq!(report.filed.len(), 1, "{:?}", report.filed);
assert_eq!(report.filed[0].0, 2, "max+1 over the one existing child");
assert_eq!(backend.titles().len(), 3, "{:?}", backend.titles());
}
#[test]
fn create_refuses_a_plan_doc_absent_from_origin_main() {
let dir = tempfile::TempDir::new().expect("tempdir");
let mut backend = FakeBackend::new();
backend.publish = None;
let err = create::create(&backend, &opts_for(&dir, None)).unwrap_err();
let text = err.to_string();
assert!(
text.contains("aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee"),
"{text}"
);
assert!(text.contains("origin/main"), "{text}");
assert_eq!(backend.calls("create_issue"), 0, "nothing was filed");
}
#[test]
fn create_refuses_a_plan_doc_git_does_not_track() {
let dir = tempfile::TempDir::new().expect("tempdir");
let mut backend = FakeBackend::new();
backend.tracked = false;
let err = create::create(&backend, &opts_for(&dir, None)).unwrap_err();
assert!(err.to_string().contains("git does not track"), "{err}");
assert_eq!(backend.calls("create_issue"), 0);
}
#[test]
fn create_refuses_a_plan_doc_with_no_epic_plan_heading() {
let dir = tempfile::TempDir::new().expect("tempdir");
let opts = opts_for(&dir, None);
std::fs::write(&opts.plan_path, "# Title\n\nNo section root here.\n").expect("write");
let backend = FakeBackend::new();
let err = create::create(&backend, &opts).unwrap_err();
assert!(err.to_string().contains("## Epic plan"), "{err}");
assert_eq!(backend.calls("create_issue"), 0);
}
#[test]
fn create_labels_every_issue_it_files() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new();
create::create(&backend, &opts_for(&dir, None)).expect("creates");
let tracker = backend.issue(100);
assert!(
tracker.labels.contains(&"epic".to_string()),
"{:?}",
tracker.labels
);
assert_eq!(tracker.milestone, "Issue management");
for number in [101, 102] {
let phase = backend.issue(number);
assert!(
phase.labels.contains(&"enhancement".to_string()),
"#{number}: {:?}",
phase.labels
);
assert!(
phase.labels.contains(&"trusty-mpm".to_string()),
"#{number}: {:?}",
phase.labels
);
assert_eq!(
phase.milestone, tracker.milestone,
"phases take the tracker's"
);
}
for issue in [tracker.clone(), backend.issue(101), backend.issue(102)] {
assert!(
issue.labels.iter().any(|l| l.starts_with("ws/")),
"{:?}",
issue.labels
);
}
}
#[test]
fn create_waives_a_project_attach_the_token_refuses() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new().fails_with("attach_project", SCOPE_REFUSAL);
let report =
create::create(&backend, &opts_for(&dir, Some(3))).expect("the run still succeeds");
assert_eq!(report.waived.len(), 3, "{:?}", report.waived);
for number in [100, 101, 102] {
let issue = backend.issue(number);
assert!(
issue.projects.is_empty(),
"#{number} attached a project anyway"
);
assert_eq!(issue.comments.len(), 1, "#{number}: {:?}", issue.comments);
assert!(
issue.comments[0].starts_with(NO_PROJECT_PREFIX),
"#{number}: {:?}",
issue.comments
);
}
}
#[test]
fn create_fails_when_the_waiver_comment_fails() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new()
.fails_with("attach_project", SCOPE_REFUSAL)
.fails("comment");
let err = create::create(&backend, &opts_for(&dir, Some(3))).unwrap_err();
assert!(
err.to_string().contains("scripted failure: comment"),
"{err}"
);
}
#[test]
fn create_propagates_a_non_scope_attach_error() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new().fails_with(
"attach_project",
"HTTP 502: Bad gateway (https://api.github.com/graphql)",
);
let err = create::create(&backend, &opts_for(&dir, Some(3))).unwrap_err();
assert!(
err.to_string().contains("not the token's project scope"),
"{err}"
);
assert!(
backend.issue(100).comments.is_empty(),
"a retryable failure must leave no waiver: {:?}",
backend.issue(100).comments
);
}
#[test]
fn a_scope_refusal_is_recognised_in_both_wordings() {
assert!(create::is_scope_refusal(SCOPE_REFUSAL));
assert!(create::is_scope_refusal(
"your token has not been granted the required scopes to execute this query. The \
'id' field requires one of the following scopes: ['read:project']"
));
assert!(!create::is_scope_refusal("HTTP 502: Bad gateway"));
assert!(!create::is_scope_refusal(
"could not resolve to a ProjectV2"
));
}
#[test]
fn create_leaves_one_waiver_per_issue_across_two_runs() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new().fails_with("attach_project", SCOPE_REFUSAL);
let opts = opts_for(&dir, Some(3));
create::create(&backend, &opts).expect("first run waives");
let report = create::create(&backend, &opts).expect("second run waives again");
assert_eq!(report.waived.len(), 3, "{:?}", report.waived);
for number in [100, 101, 102] {
assert_eq!(
backend.issue(number).comments.len(),
1,
"#{number} collected a second waiver: {:?}",
backend.issue(number).comments
);
}
}
#[test]
fn create_attaches_an_adopted_tracker_to_the_project() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new()
.fails("attach_project:1")
.fails("comment:1");
let opts = opts_for(&dir, Some(3));
create::create(&backend, &opts).expect_err("the run dies at the tracker's attach");
assert_eq!(
backend.issue(100).title,
"[EPIC 100] Automate tracker and phase-issue authoring",
"the retitle had already happened"
);
assert!(backend.issue(100).projects.is_empty());
backend.clear_failures();
create::create(&backend, &opts).expect("the re-run adopts the tracker");
assert_eq!(
backend.issue(100).projects,
vec![3],
"the adopted tracker is attached exactly once"
);
}
#[test]
fn create_attaches_a_freshly_filed_tracker_exactly_once() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new();
create::create(&backend, &opts_for(&dir, Some(3))).expect("creates");
assert_eq!(
backend.issue(100).projects,
vec![3],
"attached once, not twice"
);
}
#[test]
fn create_retitles_a_placeholder_tracker_on_re_run() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new();
backend.seed_tracker(
90,
"[EPIC] Automate tracker and phase-issue authoring",
&tracker_fixture(
"| # | Phase | Issue | State | Gate |\n|---|-------|-------|-------|------|",
),
);
let report = create::create(&backend, &opts_for(&dir, None)).expect("adopts and finishes");
assert_eq!(report.tracker, Some(90), "no duplicate tracker was filed");
assert_eq!(
backend.issue(90).title,
"[EPIC 90] Automate tracker and phase-issue authoring",
"the re-run finished the retitle the killed run owed"
);
assert_eq!(report.filed.len(), 2, "{:?}", report.filed);
assert_eq!(
backend.titles().len(),
3,
"one tracker, two phases: {:?}",
backend.titles()
);
}
#[test]
fn create_repairs_a_missing_project_waiver_on_a_skipped_phase() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new()
.fails("attach_project:2")
.fails("comment:1");
let opts = opts_for(&dir, Some(3));
create::create(&backend, &opts).expect_err("the run dies at the first phase's attach");
assert!(
backend.issue(101).projects.is_empty() && backend.issue(101).comments.is_empty(),
"the interruption left #101 with neither a project nor a waiver"
);
backend.clear_failures();
let report = create::create(&backend, &opts).expect("the re-run completes");
assert_eq!(report.skipped.len(), 1, "{:?}", report.skipped);
assert_eq!(
backend.issue(101).projects,
vec![3],
"the skip branch repaired the missing attach"
);
}
#[test]
fn create_looks_up_the_tracker_by_its_label_set() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new();
create::create(&backend, &opts_for(&dir, None)).expect("creates");
let labels = backend.find_labels.borrow().clone();
assert!(labels.contains(&"epic".to_string()), "{labels:?}");
assert!(labels.contains(&"trusty-mpm".to_string()), "{labels:?}");
assert!(labels.iter().any(|l| l.starts_with("ws/")), "{labels:?}");
}
#[test]
fn create_attaches_the_project_when_the_token_allows_it() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new();
let report = create::create(&backend, &opts_for(&dir, Some(3))).expect("creates");
assert!(report.waived.is_empty(), "{:?}", report.waived);
assert_eq!(backend.issue(101).projects, vec![3]);
assert!(backend.issue(101).comments.is_empty());
}
#[test]
fn create_refuses_when_the_tracker_search_fails() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new().fails("find_tracker");
let err = create::create(&backend, &opts_for(&dir, None)).unwrap_err();
assert!(err.to_string().contains("--tracker"), "{err}");
assert_eq!(
backend.calls("create_issue"),
0,
"no duplicate tracker was filed"
);
}
#[test]
fn create_names_the_tracker_when_the_retitle_fails() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new().fails("set_title");
let err = create::create(&backend, &opts_for(&dir, None)).unwrap_err();
let text = err.to_string();
assert!(text.contains("placeholder title"), "{text}");
assert!(text.contains("--tracker 100"), "{text}");
}
#[test]
fn create_dry_run_files_nothing() {
let dir = tempfile::TempDir::new().expect("tempdir");
let backend = FakeBackend::new();
let mut opts = opts_for(&dir, None);
opts.dry_run = true;
let report = create::create(&backend, &opts).expect("dry run");
assert!(report.dry_run);
assert_eq!(report.filed.len(), 2);
assert!(
report.plan_url.contains(PUBLISHED_SHA),
"{}",
report.plan_url
);
assert_eq!(backend.calls("create_issue"), 0);
assert_eq!(backend.calls("set_body"), 0);
}
fn tracker_fixture(phases_block: &str) -> String {
format!(
"Plan: https://github.com/o/r/blob/{PUBLISHED_SHA}/{REL_PATH}\n\
\n\
Authored prose nobody may touch. It has a trailing space here. \n\
\n\
## Phases\n\
\n\
{PHASES_START}\n{phases_block}\n{PHASES_END}\n\
\n\
## Deferred\n\
\n\
{DEFERRED_START}\n| Item | Why | Where |\n|---|---|---|\n| a thing | later | #1 |\n{DEFERRED_END}\n\
\n\
## Follow-ups\n\
\n\
{FOLLOWUPS_START}\n| Finding | Severity | Where |\n|---|---|---|\n{FOLLOWUPS_END}\n"
)
}
#[test]
fn sync_replaces_the_whole_block_and_discards_a_hand_edited_row() {
let backend = FakeBackend::new();
let body = tracker_fixture(
"| # | Phase | Issue | State | Gate |\n\
|---|-------|-------|-------|------|\n\
| 1 | phase 1 | #101 | HAND EDITED | who knows |\n\
| 9 | a phase that does not exist | #999 | open | invented |",
);
backend.seed_tracker(100, "[EPIC 100] An outcome", &body);
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "phase 1"),
"OPEN",
"## Gate\n\ngate 1\n",
);
let report = sync::sync(&backend, 100, &status_model()).expect("syncs");
assert!(!report.unchanged);
assert_eq!(report.rows, 1);
let after = backend.issue(100).body;
assert!(!after.contains("HAND EDITED"), "{after}");
assert!(!after.contains("a phase that does not exist"), "{after}");
assert!(
after.contains("| 1 | phase 1 | #101 | open | gate 1 |"),
"{after}"
);
}
#[test]
fn sync_leaves_every_byte_outside_the_markers_identical() {
let backend = FakeBackend::new();
let before = tracker_fixture(
"| # | Phase | Issue | State | Gate |\n|---|-------|-------|-------|------|",
);
backend.seed_tracker(100, "[EPIC 100] An outcome", &before);
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "phase 1"),
"CLOSED",
"## Gate\n\ngate 1\n",
);
sync::sync(&backend, 100, &status_model()).expect("syncs");
let after = backend.issue(100).body;
assert_ne!(after, before, "the block itself must change");
let split = |text: &str| -> (String, String) {
let start = text.find(PHASES_START).expect("start marker");
let end = text.find(PHASES_END).expect("end marker");
(
text[..start + PHASES_START.len()].to_string(),
text[end..].to_string(),
)
};
let (head_before, tail_before) = split(&before);
let (head_after, tail_after) = split(&after);
assert_eq!(
head_before, head_after,
"bytes before the start marker changed"
);
assert_eq!(
tail_before, tail_after,
"bytes after the end marker changed"
);
assert!(tail_after.contains(DEFERRED_START), "{tail_after}");
assert!(tail_after.contains(FOLLOWUPS_START), "{tail_after}");
}
#[test]
fn sync_refuses_a_body_with_no_markers_and_writes_nothing() {
let backend = FakeBackend::new();
let before = "Authored prose with no markers at all.\n";
backend.seed_tracker(100, "[EPIC 100] An outcome", before);
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "p"),
"OPEN",
"## Gate\n\ng\n",
);
let err = sync::sync(&backend, 100, &status_model()).unwrap_err();
assert!(err.to_string().contains(PHASES_START), "{err}");
assert_eq!(
backend.issue(100).body,
before,
"the body must be untouched"
);
assert_eq!(backend.calls("set_body"), 0);
}
#[test]
fn sync_refuses_a_body_with_two_start_markers_and_writes_nothing() {
let backend = FakeBackend::new();
let before =
format!("prose\n{PHASES_START}\n| a |\n{PHASES_START}\n| b |\n{PHASES_END}\nmore prose\n");
backend.seed_tracker(100, "[EPIC 100] An outcome", &before);
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "p"),
"OPEN",
"## Gate\n\ng\n",
);
let err = sync::sync(&backend, 100, &status_model()).unwrap_err();
assert!(err.to_string().contains("exactly one is required"), "{err}");
assert_eq!(
backend.issue(100).body,
before,
"the body must be untouched"
);
assert_eq!(backend.calls("set_body"), 0);
}
#[test]
fn sync_is_a_no_op_when_the_block_already_matches() {
let backend = FakeBackend::new();
let child_title = render::phase_title(100, 1, "phase 1");
backend.seed_tracker(
100,
"[EPIC 100] An outcome",
&tracker_fixture(
"| # | Phase | Issue | State | Gate |\n|---|-------|-------|-------|------|",
),
);
backend.seed_child(100, 101, &child_title, "OPEN", "## Gate\n\ngate 1\n");
sync::sync(&backend, 100, &status_model()).expect("first sync writes");
assert_eq!(backend.calls("set_body"), 1);
let report = sync::sync(&backend, 100, &status_model()).expect("second sync is a no-op");
assert!(report.unchanged, "a matching block must not be rewritten");
assert_eq!(backend.calls("set_body"), 1, "no second write");
}
#[test]
fn sync_propagates_a_failed_body_write() {
let backend = FakeBackend::new().fails("set_body");
backend.seed_tracker(
100,
"[EPIC 100] An outcome",
&tracker_fixture(
"| # | Phase | Issue | State | Gate |\n|---|-------|-------|-------|------|",
),
);
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "p"),
"OPEN",
"## Gate\n\ng\n",
);
let err = sync::sync(&backend, 100, &status_model()).unwrap_err();
assert!(
err.to_string().contains("scripted failure: set_body"),
"{err}"
);
}
#[test]
fn sync_propagates_a_failed_child_read() {
let backend = FakeBackend::new().fails("children");
backend.seed_tracker(
100,
"[EPIC 100] An outcome",
&tracker_fixture(
"| # | Phase | Issue | State | Gate |\n|---|-------|-------|-------|------|",
),
);
let err = sync::sync(&backend, 100, &status_model()).unwrap_err();
assert!(
err.to_string().contains("scripted failure: children"),
"{err}"
);
assert_eq!(backend.calls("set_body"), 0);
}
struct FakeRunner {
outputs: RefCell<Vec<CommandOutput>>,
calls: RefCell<Vec<Vec<String>>>,
}
impl FakeRunner {
fn new(outputs: Vec<CommandOutput>) -> Self {
Self {
outputs: RefCell::new(outputs),
calls: RefCell::new(Vec::new()),
}
}
}
impl CommandRunner for FakeRunner {
fn run(&self, program: &str, args: &[&str]) -> anyhow::Result<CommandOutput> {
let mut call = vec![program.to_string()];
call.extend(args.iter().map(|a| (*a).to_string()));
self.calls.borrow_mut().push(call);
let mut outs = self.outputs.borrow_mut();
if outs.is_empty() {
anyhow::bail!("FakeRunner exhausted")
}
Ok(outs.remove(0))
}
}
fn ok_out(stdout: &str) -> CommandOutput {
CommandOutput {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
}
}
#[test]
fn gh_backend_creates_an_issue_and_reads_its_number() {
let runner = FakeRunner::new(vec![ok_out(
"https://github.com/bobmatnyc/trusty-tools/issues/8451\n",
)]);
let spec = NewIssue {
title: "[EPIC_8445 PHASE_1] do it".to_string(),
body: "Part of #8445.".to_string(),
labels: vec!["enhancement".to_string(), "ws/x".to_string()],
milestone: "Issue management".to_string(),
parent: Some(8445),
};
let backend = super::backend::GhEpicBackend::new(runner);
assert_eq!(backend.create_issue(&spec).expect("creates"), 8451);
}
#[test]
fn gh_backend_refuses_a_create_whose_url_carries_no_number() {
let runner = FakeRunner::new(vec![ok_out("Creating issue in bobmatnyc/trusty-tools\n")]);
let spec = NewIssue {
title: "t".to_string(),
body: "b".to_string(),
labels: vec![],
milestone: "m".to_string(),
parent: None,
};
let backend = super::backend::GhEpicBackend::new(runner);
let err = backend.create_issue(&spec).unwrap_err();
assert!(err.to_string().contains("nothing filed"), "{err}");
assert!(issue_number_from_url("").is_err());
assert_eq!(
issue_number_from_url("https://x/issues/7").expect("parses"),
7
);
}
#[test]
fn gh_backend_parses_the_sub_issue_connection() {
let runner = FakeRunner::new(vec![
ok_out(
r#"{"subIssues":{"nodes":[{"number":8447,"title":"[EPIC_8445 PHASE_1] a","state":"OPEN"}],"totalCount":1}}"#,
),
ok_out("{\"body\":\"## Gate\\n\\nnone\\n\"}"),
]);
let backend = super::backend::GhEpicBackend::new(runner);
let children = backend.children(8445).expect("parses");
assert_eq!(children.len(), 1);
assert_eq!(children[0].number, 8447);
assert_eq!(children[0].state, "OPEN");
assert!(
children[0].body.contains("## Gate"),
"{:?}",
children[0].body
);
}
#[test]
fn gh_backend_pages_a_truncated_sub_issue_connection() {
let runner = FakeRunner::new(vec![
ok_out(
r#"{"subIssues":{"nodes":[{"number":1,"title":"[EPIC_9 PHASE_1] a","state":"OPEN"}],"totalCount":2}}"#,
),
ok_out(
r#"[{"number":1,"title":"[EPIC_9 PHASE_1] a","state":"open"}][{"number":2,"title":"[EPIC_9 PHASE_2] b","state":"closed"}]"#,
),
ok_out("{\"body\":\"## Gate\\n\\nga\\n\"}"),
ok_out("{\"body\":\"## Gate\\n\\ngb\\n\"}"),
]);
let backend = super::backend::GhEpicBackend::new(runner);
let children = backend.children(9).expect("pages");
assert_eq!(
children.len(),
2,
"the short page was replaced, not trusted"
);
assert_eq!(children[1].number, 2);
assert_eq!(children[1].state, "closed");
}
#[test]
fn gh_backend_trusts_a_complete_sub_issue_page() {
let runner = FakeRunner::new(vec![
ok_out(
r#"{"subIssues":{"nodes":[{"number":1,"title":"[EPIC_9 PHASE_1] a","state":"OPEN"}],"totalCount":1}}"#,
),
ok_out("{\"body\":\"## Gate\\n\\nga\\n\"}"),
]);
let backend = super::backend::GhEpicBackend::new(runner);
assert_eq!(backend.children(9).expect("reads").len(), 1);
}
#[test]
fn gh_backend_refuses_a_full_page_rather_than_reporting_no_tracker() {
let labels = vec!["epic".to_string(), "ws/x".to_string()];
let rows: Vec<String> = (1..=200)
.map(|n| format!(r#"{{"number":{n},"title":"unrelated {n}"}}"#))
.collect();
let runner = FakeRunner::new(vec![ok_out(&format!("[{}]", rows.join(",")))]);
let backend = super::backend::GhEpicBackend::new(runner);
let err = backend.find_tracker("An outcome", &labels).unwrap_err();
assert!(err.to_string().contains("full page"), "{err}");
assert!(err.to_string().contains("--tracker"), "{err}");
}
#[test]
fn gh_backend_finds_a_tracker_in_a_label_filtered_listing() {
let labels = vec!["epic".to_string(), "ws/x".to_string()];
let runner = FakeRunner::new(vec![ok_out(
r#"[{"number":8445,"title":"[EPIC 8445] An outcome"},{"number":9,"title":"An outcome"}]"#,
)]);
let backend = super::backend::GhEpicBackend::new(runner);
assert_eq!(
backend.find_tracker("An outcome", &labels).expect("reads"),
Some(FoundTracker {
number: 8445,
placeholder: false
}),
"only the title carrying the issue's OWN number is a final-form tracker"
);
}
#[test]
fn gh_backend_pages_a_body_carrying_a_reference_style_link() {
let runner = FakeRunner::new(vec![
ok_out(
r#"{"subIssues":{"nodes":[{"number":1,"title":"[EPIC_9 PHASE_1] a","state":"OPEN"}],"totalCount":2}}"#,
),
ok_out(
r#"[{"number":1,"title":"see [the spec][ref]","state":"open"},{"number":2,"title":"[EPIC_9 PHASE_2] b","state":"closed"}]"#,
),
ok_out("{\"body\":\"## Gate\\n\\nga\\n\"}"),
ok_out("{\"body\":\"## Gate\\n\\ngb\\n\"}"),
]);
let backend = super::backend::GhEpicBackend::new(runner);
let children = backend
.children(9)
.expect("parses a body with a `][` in it");
assert_eq!(children.len(), 2);
assert_eq!(children[0].title, "see [the spec][ref]");
}
#[test]
fn gh_backend_finds_a_placeholder_titled_tracker() {
let labels = vec!["epic".to_string()];
let runner = FakeRunner::new(vec![ok_out(
r#"[{"number":8445,"title":"[EPIC] An outcome"}]"#,
)]);
let backend = super::backend::GhEpicBackend::new(runner);
let found = backend
.find_tracker("An outcome", &labels)
.expect("reads")
.expect("the placeholder is a match");
assert_eq!(found.number, 8445);
assert!(found.placeholder, "and it reports WHICH form matched");
let runner = FakeRunner::new(vec![ok_out(
r#"[{"number":8445,"title":"[EPIC 8445] An outcome"},{"number":8446,"title":"[EPIC] An outcome"}]"#,
)]);
let backend = super::backend::GhEpicBackend::new(runner);
let err = backend.find_tracker("An outcome", &labels).unwrap_err();
assert!(err.to_string().contains("--tracker"), "{err}");
}
#[test]
fn gh_backend_reads_an_issues_comment_bodies() {
let runner = FakeRunner::new(vec![ok_out(
r#"{"comments":[{"body":"no-project: missing scopes"},{"body":"unrelated"}]}"#,
)]);
let backend = super::backend::GhEpicBackend::new(runner);
let comments = backend.comments(8445).expect("reads");
assert_eq!(comments.len(), 2);
assert!(comments[0].starts_with(NO_PROJECT_PREFIX), "{comments:?}");
}
#[test]
fn gh_backend_reports_an_absent_plan_doc_as_none() {
let backend = super::backend::GhEpicBackend::new(FakeRunner::new(vec![ok_out("\n")]));
assert_eq!(backend.publish_sha(REL_PATH).expect("runs"), None);
let backend = super::backend::GhEpicBackend::new(FakeRunner::new(vec![ok_out(PUBLISHED_SHA)]));
assert_eq!(
backend.publish_sha(REL_PATH).expect("runs").as_deref(),
Some(PUBLISHED_SHA)
);
}
#[test]
fn gh_backend_refuses_to_write_an_empty_body() {
let runner = FakeRunner::new(vec![ok_out("")]);
let backend = super::backend::GhEpicBackend::new(runner);
let err = backend.set_body(8445, " \n").unwrap_err();
assert!(
err.to_string().contains("refusing to write an empty body"),
"{err}"
);
}
#[test]
fn epic_create_requires_a_milestone() {
let err = super::require_milestone(None).unwrap_err();
assert!(err.to_string().contains("--milestone"), "{err}");
assert!(super::require_milestone(Some(" ".to_string())).is_err());
assert_eq!(
super::require_milestone(Some("Issue management".to_string())).expect("accepts"),
"Issue management"
);
}
#[test]
fn epic_create_requires_a_component() {
let err = super::require_components(vec![]).unwrap_err();
assert!(err.to_string().contains("--component"), "{err}");
assert!(super::require_components(vec![" ".to_string()]).is_err());
assert_eq!(
super::require_components(vec!["trusty-mpm".to_string()]).expect("accepts"),
vec!["trusty-mpm".to_string()]
);
assert_eq!(
super::require_components(vec![String::new(), "api".to_string()]).expect("accepts"),
vec!["api".to_string()],
"a blank entry is dropped, never forwarded as a label"
);
}
#[test]
fn epic_create_requires_a_session_name() {
assert_eq!(
super::require_session(Some("tm-trusty-tools-15".to_string())).expect("accepts"),
"tm-trusty-tools-15"
);
match super::require_session(None) {
Ok(name) => assert!(!name.trim().is_empty(), "a derived name is never blank"),
Err(e) => assert!(e.to_string().contains("--session"), "{e}"),
}
}
fn labelled_child(state: &str, labels: &[&str]) -> ChildIssue {
ChildIssue {
number: 5,
title: render::phase_title(1, 1, "x"),
state: state.to_string(),
labels: labels.iter().map(|l| (*l).to_string()).collect(),
body: "## Gate\n\ng\n".to_string(),
}
}
#[test]
fn state_cell_reads_closed_for_a_closed_child() {
assert_eq!(
render::state_cell(
&labelled_child("CLOSED", &["status:tested"]),
&status_model()
),
"closed"
);
}
#[test]
fn state_cell_reads_the_status_label_of_an_open_child() {
for state in ["in-progress", "coded", "merged", "tested"] {
let label = format!("status:{state}");
let child = labelled_child("OPEN", &["trusty-mpm", &label]);
assert_eq!(render::state_cell(&child, &status_model()), state);
let table = render::phases_table(std::slice::from_ref(&child), &status_model());
assert!(table.contains(&format!("| #5 | {state} |")), "{table}");
}
}
#[test]
fn state_cell_reads_open_for_an_unlabelled_open_child() {
assert_eq!(
render::state_cell(
&labelled_child("OPEN", &["trusty-mpm", "enhancement"]),
&status_model()
),
"open"
);
}
#[test]
fn phases_table_uses_the_configured_status_prefix() {
let child = labelled_child("OPEN", &["unicorn", "unicorn:approved"]);
assert_eq!(render::state_cell(&child, &model()), "approved");
assert_eq!(render::state_cell(&child, &status_model()), "open");
let table = render::phases_table(std::slice::from_ref(&child), &model());
assert!(table.contains("| #5 | approved |"), "{table}");
assert_eq!(model().label_config.status_prefix, "unicorn:");
}
#[test]
fn state_cell_resolves_a_label_whose_name_lacks_the_prefix() {
let child = labelled_child("OPEN", &["enhancement", "in-progress"]);
assert_eq!(
render::state_cell(&child, &prefixless_model()),
"in-progress"
);
let table = render::phases_table(std::slice::from_ref(&child), &prefixless_model());
assert!(table.contains("| #5 | in-progress |"), "{table}");
}
#[test]
fn state_cell_resolves_a_label_name_unrelated_to_the_state_name() {
let mut model = prefixless_model();
let in_progress = model
.states
.iter_mut()
.find(|s| s.name == "status:in-progress")
.and_then(|s| s.label.as_mut())
.expect("the fixture labels status:in-progress");
in_progress.name = "wip".to_string();
let child = labelled_child("OPEN", &["wip"]);
assert_eq!(render::state_cell(&child, &model), "in-progress");
}
#[test]
fn state_cell_reads_open_for_a_prefixed_label_the_model_lacks() {
let child = labelled_child("OPEN", &["unicorn", "unicorn:coded"]);
assert_eq!(render::state_cell(&child, &model()), "open");
let table = render::phases_table(std::slice::from_ref(&child), &model());
assert!(table.contains("| #5 | open |"), "{table}");
let foreign = labelled_child("OPEN", &["unicorn", "status:coded"]);
assert_eq!(render::state_cell(&foreign, &model()), "open");
}
#[test]
fn state_cell_under_an_empty_prefix_reads_only_model_labels() {
let mut model = prefixless_model();
model.label_config.status_prefix = String::new();
let child = labelled_child("OPEN", &["bug", "in-progress"]);
assert_eq!(render::state_cell(&child, &model), "status:in-progress");
let renamed = model
.states
.iter_mut()
.find(|s| s.name == "status:in-progress")
.expect("the fixture has the state");
renamed.name = "in-progress".to_string();
assert_eq!(render::state_cell(&child, &model), "in-progress");
}
#[test]
fn state_cell_joins_several_model_states_sorted() {
let child = labelled_child("OPEN", &["status:merged", "status:coded"]);
assert_eq!(render::state_cell(&child, &status_model()), "coded/merged");
}
#[test]
fn outcomes_of_folds_wrapped_continuation_lines() {
let body = "## Outcomes\n\
\n\
- **O1** A tracker and its phase issues are created from a committed plan\n\
\x20 document by one command, with titles, labels, milestone and native sub-issue\n\
\x20 links applied without anyone typing them.\n\
- **O2** The `phases` block is regenerated from live child state by a command,\n\
\x20 never by a session retyping a table, and content outside the markers is\n\
\x20 provably untouched.\n\
- **O3** A phase transition updates its tracker without a human remembering to,\n\
\x20 so a closed phase never leaves a stale tracker.\n\
- **O4** `tm issue audit` reports a tracker whose block has drifted from its\n\
\x20 children, so the guarantee is checked rather than asserted.\n\
\n\
\x20 not an outcome: a blank line ended the fold\n\
\n\
## Ratified decisions\n\
\n\
- **O9** a heading ended the section\n";
let outcomes = render::outcomes_of(body);
assert_eq!(outcomes.len(), 4, "{outcomes:?}");
assert_eq!(outcomes[0].0, "O1");
assert_eq!(
outcomes[0].1,
"A tracker and its phase issues are created from a committed plan document by one \
command, with titles, labels, milestone and native sub-issue links applied without \
anyone typing them."
);
assert_eq!(
outcomes[3].1,
"`tm issue audit` reports a tracker whose block has drifted from its children, so the \
guarantee is checked rather than asserted."
);
assert!(!outcomes[3].1.contains("not an outcome"), "{outcomes:?}");
}
fn defer_opts() -> DeferOptions {
DeferOptions {
item: "The | pipe thing".to_string(),
why: "later".to_string(),
destination: "unscheduled".to_string(),
}
}
#[test]
fn defer_appends_exactly_one_row() {
let backend = FakeBackend::new();
let before = tracker_fixture("| # |");
backend.seed_tracker(100, "[EPIC 100] An outcome", &before);
let report = defer::defer(&backend, 100, &defer_opts()).expect("defers");
assert!(!report.unchanged);
let after = backend.issue(100).body;
let block = render::block_content(&after, DEFERRED_START, DEFERRED_END).expect("block");
let before_block = render::block_content(&before, DEFERRED_START, DEFERRED_END).expect("b");
assert_eq!(
block.lines().count(),
before_block.lines().count() + 1,
"{block}"
);
assert!(
block.ends_with(r"| The \| pipe thing | later | unscheduled |"),
"{block}"
);
}
#[test]
fn defer_leaves_the_phases_and_followups_blocks_byte_identical() {
let backend = FakeBackend::new();
let before = tracker_fixture("| 1 | phase 1 | #101 | in-progress | gate 1 |");
backend.seed_tracker(100, "[EPIC 100] An outcome", &before);
defer::defer(&backend, 100, &defer_opts()).expect("defers");
let after = backend.issue(100).body;
assert_ne!(after, before);
let split = |text: &str| -> (String, String) {
let start = text.find(DEFERRED_START).expect("start");
let end = text.find(DEFERRED_END).expect("end");
(
text[..start + DEFERRED_START.len()].to_string(),
text[end..].to_string(),
)
};
let (head_before, tail_before) = split(&before);
let (head_after, tail_after) = split(&after);
assert_eq!(
head_before, head_after,
"bytes before the deferred block changed"
);
assert_eq!(
tail_before, tail_after,
"bytes after the deferred block changed"
);
assert!(head_after.contains(PHASES_START) && head_after.contains(PHASES_END));
assert!(tail_after.contains(FOLLOWUPS_START) && tail_after.contains(FOLLOWUPS_END));
}
#[test]
fn defer_is_a_no_op_when_the_row_is_already_present() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
defer::defer(&backend, 100, &defer_opts()).expect("first");
let report = defer::defer(&backend, 100, &defer_opts()).expect("second");
assert!(report.unchanged, "the same row must not stack");
assert_eq!(backend.calls("set_body"), 1);
}
#[test]
fn defer_refuses_a_blank_cell_and_writes_nothing() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
let mut opts = defer_opts();
opts.why = " ".to_string();
let err = defer::defer(&backend, 100, &opts).unwrap_err();
assert!(err.to_string().contains("--why"), "{err}");
assert_eq!(backend.calls("body"), 0, "refused before any read");
assert_eq!(backend.calls("set_body"), 0);
}
#[test]
fn defer_seeds_the_header_into_an_empty_block() {
let backend = FakeBackend::new();
let body = format!("prose\n{DEFERRED_START}\n{DEFERRED_END}\n");
backend.seed_tracker(100, "[EPIC 100] An outcome", &body);
defer::defer(&backend, 100, &defer_opts()).expect("defers");
let after = backend.issue(100).body;
assert!(
after.contains("| Item | Why deferred | Where it went |"),
"{after}"
);
assert!(after.starts_with("prose\n"), "{after}");
}
fn tracker_with_outcomes() -> String {
format!(
"## Outcomes\n\n- **O1** The first outcome.\n- **O2** The second outcome.\n\n{}",
tracker_fixture("| # |")
)
}
fn two_evidence() -> Vec<String> {
vec![
"O1: proved by #101".to_string(),
"O2 proved by #102".to_string(),
]
}
fn seed_closable(backend: &FakeBackend, child_states: &[&str]) {
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_with_outcomes());
for (i, state) in child_states.iter().enumerate() {
let n = 101 + i as u64;
let title = render::phase_title(100, i as u64 + 1, &format!("phase {}", i + 1));
backend.seed_child(100, n, &title, state, "## Gate\n\ng\n");
}
}
#[test]
fn close_refuses_while_a_child_is_open_and_names_it() {
let backend = FakeBackend::new();
seed_closable(&backend, &["CLOSED", "OPEN"]);
let err = close::close(&backend, 100, &two_evidence()).unwrap_err();
assert!(err.to_string().contains("#102"), "{err}");
assert!(!err.to_string().contains("#101"), "{err}");
assert_eq!(backend.calls("comment"), 0);
assert_eq!(backend.calls("close_issue"), 0);
assert_eq!(backend.issue(100).state, "OPEN");
}
#[test]
fn close_succeeds_once_every_child_is_closed() {
let backend = FakeBackend::new();
seed_closable(&backend, &["CLOSED", "OPEN"]);
close::close(&backend, 100, &two_evidence()).expect_err("refused while #102 is open");
backend
.issues
.borrow_mut()
.get_mut(&102)
.expect("#102")
.state = "CLOSED".to_string();
let report = close::close(&backend, 100, &two_evidence()).expect("closes");
assert_eq!(report.phases, vec![101, 102]);
assert!(report.comment_posted);
assert_eq!(backend.issue(100).state, "CLOSED");
}
#[test]
fn close_posts_one_line_per_declared_outcome() {
let backend = FakeBackend::new();
seed_closable(&backend, &["CLOSED"]);
let report = close::close(&backend, 100, &two_evidence()).expect("closes");
assert_eq!(report.outcomes, 2);
let comments = backend.issue(100).comments;
assert_eq!(comments.len(), 1, "{comments:?}");
let comment = &comments[0];
assert!(comment.starts_with(CLOSE_COMMENT_PREFIX), "{comment}");
assert!(
comment.contains("- **O1** The first outcome. — proved by #101"),
"{comment}"
);
assert!(
comment.contains("- **O2** The second outcome. — proved by #102"),
"{comment}"
);
assert_eq!(comment.matches("\n- **O").count(), 2, "{comment}");
}
#[test]
fn close_refuses_a_tracker_with_no_children() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_with_outcomes());
let err = close::close(&backend, 100, &two_evidence()).unwrap_err();
assert!(err.to_string().contains("no native sub-issues"), "{err}");
assert_eq!(backend.calls("close_issue"), 0);
}
#[test]
fn close_refuses_a_tracker_that_declares_no_outcomes() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "p"),
"CLOSED",
"## Gate\n\ng\n",
);
let err = close::close(&backend, 100, &two_evidence()).unwrap_err();
assert!(err.to_string().contains("## Outcomes"), "{err}");
assert_eq!(backend.calls("comment"), 0);
assert_eq!(backend.calls("close_issue"), 0);
}
#[test]
fn close_refuses_evidence_that_does_not_match_the_declared_outcomes() {
let backend = FakeBackend::new();
seed_closable(&backend, &["CLOSED"]);
let err = close::close(
&backend,
100,
&["O1: yes".to_string(), "O3: invented".to_string()],
)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("missing [O2]"), "{text}");
assert!(text.contains("unknown [O3]"), "{text}");
let err = close::close(&backend, 100, &["O1: a".to_string(), "O1: b".to_string()]).unwrap_err();
assert!(err.to_string().contains("twice"), "{err}");
let err = close::close(&backend, 100, &["O1:".to_string()]).unwrap_err();
assert!(err.to_string().contains("O<n>: <what proves it>"), "{err}");
assert_eq!(backend.calls("comment"), 0);
assert_eq!(backend.calls("close_issue"), 0);
}
#[test]
fn close_does_not_post_a_second_comment_on_re_run() {
let backend = FakeBackend::new().fails("close_issue:1");
seed_closable(&backend, &["CLOSED"]);
let err = close::close(&backend, 100, &two_evidence()).unwrap_err();
assert!(err.to_string().contains("still open"), "{err}");
assert_eq!(backend.issue(100).comments.len(), 1);
assert_eq!(backend.issue(100).state, "OPEN");
backend.clear_failures();
let report = close::close(&backend, 100, &two_evidence()).expect("re-run closes");
assert!(
!report.comment_posted,
"the comment was already on the record"
);
assert_eq!(
backend.issue(100).comments.len(),
1,
"one closing comment, not two"
);
assert_eq!(backend.issue(100).state, "CLOSED");
}
#[test]
fn close_propagates_a_failed_close_call() {
let backend = FakeBackend::new().fails("close_issue");
seed_closable(&backend, &["CLOSED"]);
let err = close::close(&backend, 100, &two_evidence()).unwrap_err();
assert!(
err.to_string().contains("scripted failure: close_issue"),
"{err}"
);
assert!(err.to_string().contains("#100"), "{err}");
}
fn model() -> StateModel {
serde_yaml::from_str(DEFAULT_MODEL_YAML).expect("the default model parses")
}
fn seed_hookable(backend: &FakeBackend, parent: Option<u64>) -> FakeTickets {
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
let title = render::phase_title(100, 1, "phase 1");
backend.issues.borrow_mut().insert(
101,
FakeIssue {
title: title.clone(),
body: "## Gate\n\ng\n".to_string(),
state: "OPEN".to_string(),
parent,
labels: vec!["unicorn:approved".to_string()],
..FakeIssue::default()
},
);
FakeTickets::new(101, &title, &["unicorn", "unicorn:queued"])
}
#[test]
fn transition_hook_regenerates_the_parent_trackers_block() {
let backend = FakeBackend::new();
let tickets = seed_hookable(&backend, Some(100));
let hooked =
hook::transition_with_tracker_sync(&tickets, &backend, &model(), 101, "approved", None)
.expect("transitions and syncs");
assert!(!hooked.report.no_op);
let synced = hooked.synced.expect("the tracker was synced");
assert_eq!(synced.tracker, 100);
assert_eq!(tickets.count("swap_labels"), 1);
assert!(
backend
.issue(100)
.body
.contains("| 1 | phase 1 | #101 | approved | g |"),
"{}",
backend.issue(100).body
);
}
#[test]
fn transition_hook_fails_the_command_when_the_sync_fails() {
let backend = FakeBackend::new().fails("set_body");
let tickets = seed_hookable(&backend, Some(100));
let err =
hook::transition_with_tracker_sync(&tickets, &backend, &model(), 101, "approved", None)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("tracker #100"), "{text}");
assert!(text.contains("tm issue epic sync 100"), "{text}");
assert!(text.contains("is now `approved`"), "{text}");
assert_eq!(
tickets.count("swap_labels"),
1,
"the label had already moved"
);
assert_eq!(backend.calls("set_body"), 1, "the write was attempted once");
}
#[test]
fn transition_hook_makes_no_epic_call_for_a_non_phase_title() {
let backend = FakeBackend::new();
let tickets = FakeTickets::new(7, "a plain issue", &["unicorn", "unicorn:queued"]);
let hooked =
hook::transition_with_tracker_sync(&tickets, &backend, &model(), 7, "approved", None)
.expect("transitions");
assert!(hooked.synced.is_none());
assert_eq!(tickets.count("swap_labels"), 1);
assert_eq!(backend.total_calls(), 0, "{:?}", backend.counts.borrow());
}
#[test]
fn transition_hook_reads_no_tracker_for_a_parentless_phase() {
let backend = FakeBackend::new();
let tickets = seed_hookable(&backend, None);
let hooked =
hook::transition_with_tracker_sync(&tickets, &backend, &model(), 101, "approved", None)
.expect("transitions");
assert!(hooked.synced.is_none());
assert_eq!(backend.calls("parent"), 1);
assert_eq!(backend.total_calls(), 1, "{:?}", backend.counts.borrow());
}
#[test]
fn transition_hook_regenerates_a_stale_tracker_on_a_no_op_transition() {
let backend = FakeBackend::new();
let tickets = seed_hookable(&backend, Some(100));
let hooked =
hook::transition_with_tracker_sync(&tickets, &backend, &model(), 101, "queued", None)
.expect("no-op transitions and syncs");
assert!(hooked.report.no_op);
assert_eq!(tickets.count("swap_labels"), 0, "the no-op wrote no label");
let synced = hooked.synced.expect("the stale tracker was synced");
assert_eq!(synced.tracker, 100);
assert!(!synced.unchanged, "the block was stale, so it was written");
assert_eq!(backend.calls("set_body"), 1);
assert!(
backend
.issue(100)
.body
.contains("| 1 | phase 1 | #101 | approved | g |"),
"{}",
backend.issue(100).body
);
let again =
hook::transition_with_tracker_sync(&tickets, &backend, &model(), 101, "queued", None)
.expect("no-op");
assert!(again.synced.expect("synced").unchanged);
assert_eq!(backend.calls("set_body"), 1, "no second write");
}
#[test]
fn transition_hook_makes_no_epic_call_for_a_no_op_on_a_non_phase_title() {
let backend = FakeBackend::new();
let tickets = FakeTickets::new(7, "a plain issue", &["unicorn", "unicorn:queued"]);
let hooked =
hook::transition_with_tracker_sync(&tickets, &backend, &model(), 7, "queued", None)
.expect("no-op");
assert!(hooked.report.no_op);
assert!(hooked.synced.is_none());
assert_eq!(tickets.count("swap_labels"), 0);
assert_eq!(backend.total_calls(), 0, "{:?}", backend.counts.borrow());
}
#[test]
fn transition_hook_names_the_tracker_when_the_parent_read_fails() {
let backend = FakeBackend::new().fails("parent");
let tickets = seed_hookable(&backend, Some(100));
let err =
hook::transition_with_tracker_sync(&tickets, &backend, &model(), 101, "approved", None)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("tracker #100"), "{text}");
assert!(text.contains("tm issue epic sync 100"), "{text}");
assert_eq!(render::epic_number_of("[EPIC_8445 PHASE_2] x"), Some(8445));
assert_eq!(render::epic_number_of("[EPIC 8445] x"), None);
}
fn seed_prefixless_phase(backend: &FakeBackend) -> FakeTickets {
let title = render::phase_title(100, 1, "phase 1");
backend.seed_tracker(
100,
"[EPIC 100] An outcome",
&tracker_fixture(
"| # | Phase | Issue | State | Gate |\n|---|-------|-------|-------|------|\n\
| 1 | phase 1 | #101 | open | g |",
),
);
backend.issues.borrow_mut().insert(
101,
FakeIssue {
title: title.clone(),
body: "## Gate\n\ng\n".to_string(),
state: "OPEN".to_string(),
parent: Some(100),
labels: vec!["enhancement".to_string(), "in-progress".to_string()],
..FakeIssue::default()
},
);
FakeTickets::new(101, &title, &["enhancement"])
}
#[test]
fn transition_hook_regenerates_a_row_for_a_prefix_less_label() {
let backend = FakeBackend::new();
let tickets = seed_prefixless_phase(&backend);
let hooked = hook::transition_with_tracker_sync(
&tickets,
&backend,
&prefixless_model(),
101,
"status:in-progress",
None,
)
.expect("transitions and syncs");
assert!(!hooked.report.no_op);
let synced = hooked.synced.expect("the tracker was synced");
assert!(
!synced.unchanged,
"the `open` row was stale, so it was written"
);
assert!(
backend
.issue(100)
.body
.contains("| 1 | phase 1 | #101 | in-progress | g |"),
"{}",
backend.issue(100).body
);
}
fn row<'a>(
rows: &'a [trusty_mpm::core::issue_audit::AuditRow],
req: &str,
) -> &'a trusty_mpm::core::issue_audit::AuditRow {
rows.iter()
.find(|r| r.requirement == req)
.unwrap_or_else(|| panic!("no {req} row in {rows:?}"))
}
#[test]
fn audit_rows_pass_a_tracker_whose_block_matches() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "p"),
"OPEN",
"## Gate\n\ng\n",
);
sync::sync(&backend, 100, &status_model()).expect("bring the block current");
let rows = audit_rows::epic_rows(&backend, 100, &status_model()).expect("rows");
assert_eq!(rows.len(), 2, "{rows:?}");
assert_eq!(row(&rows, REQ_PHASES_BLOCK).verdict, Verdict::Pass);
assert_eq!(row(&rows, REQ_PHASE_LINKAGE).verdict, Verdict::Pass);
assert_eq!(
row(&rows, REQ_PHASE_LINKAGE).detail,
"1 of 1 linked phases seen by search"
);
}
#[test]
fn audit_rows_fail_when_search_omits_a_linked_phase() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
for (n, phase) in [(101, 1), (102, 2)] {
backend.seed_child(
100,
n,
&render::phase_title(100, phase, "p"),
"OPEN",
"## Gate\n\ng\n",
);
}
sync::sync(&backend, 100, &status_model()).expect("current");
backend.hide_from_search(102);
let rows = audit_rows::epic_rows(&backend, 100, &status_model()).expect("rows");
let linkage = row(&rows, REQ_PHASE_LINKAGE);
assert_eq!(linkage.verdict, Verdict::Fail, "{linkage:?}");
assert_eq!(
linkage.detail,
"search index returned 1 of 2 known phases — re-run in a minute"
);
assert_eq!(backend.calls("phase_titled_issues"), 1);
}
#[test]
fn audit_rows_report_info_when_no_linked_phase_cross_checks_the_search() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
sync::sync(&backend, 100, &status_model()).expect("an empty block is current");
let rows = audit_rows::epic_rows(&backend, 100, &status_model()).expect("rows");
let linkage = row(&rows, REQ_PHASE_LINKAGE);
assert_eq!(linkage.verdict, Verdict::Info, "{linkage:?}");
assert!(
linkage.detail.contains("cannot be cross-checked"),
"{}",
linkage.detail
);
assert_eq!(row(&rows, REQ_PHASES_BLOCK).verdict, Verdict::Pass);
backend.seed_tracker(102, &render::phase_title(100, 2, "orphan"), "body");
let rows = audit_rows::epic_rows(&backend, 100, &status_model()).expect("rows");
let linkage = row(&rows, REQ_PHASE_LINKAGE);
assert_eq!(linkage.verdict, Verdict::Fail, "{linkage:?}");
assert!(
linkage.detail.contains("--add-sub-issue 102"),
"{}",
linkage.detail
);
}
#[test]
fn audit_rows_fail_a_stale_phases_block_with_the_sync_command() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "p"),
"OPEN",
"## Gate\n\ng\n",
);
let rows = audit_rows::epic_rows(&backend, 100, &status_model()).expect("rows");
let block = row(&rows, REQ_PHASES_BLOCK);
assert_eq!(block.verdict, Verdict::Fail);
assert_eq!(
block.detail,
"phases block stale — run tm issue epic sync 100"
);
assert_eq!(backend.calls("set_body"), 0, "an audit never writes");
}
#[test]
fn audit_rows_fail_a_stale_block_hidden_behind_a_prefix_less_label() {
let backend = FakeBackend::new();
let _tickets = seed_prefixless_phase(&backend);
let rows = audit_rows::epic_rows(&backend, 100, &prefixless_model()).expect("rows");
let block = row(&rows, REQ_PHASES_BLOCK);
assert_eq!(block.verdict, Verdict::Fail, "{block:?}");
assert_eq!(
block.detail,
"phases block stale — run tm issue epic sync 100"
);
}
#[test]
fn audit_rows_fail_a_phase_titled_issue_that_is_not_a_sub_issue() {
let backend = FakeBackend::new();
backend.seed_tracker(100, "[EPIC 100] An outcome", &tracker_fixture("| # |"));
backend.seed_child(
100,
101,
&render::phase_title(100, 1, "p"),
"OPEN",
"## Gate\n\ng\n",
);
sync::sync(&backend, 100, &status_model()).expect("current");
backend.seed_tracker(102, &render::phase_title(100, 2, "orphan"), "body");
backend.seed_tracker(103, &render::phase_title(999, 1, "elsewhere"), "body");
let rows = audit_rows::epic_rows(&backend, 100, &status_model()).expect("rows");
let linkage = row(&rows, REQ_PHASE_LINKAGE);
assert_eq!(linkage.verdict, Verdict::Fail, "{linkage:?}");
assert!(linkage.detail.contains("#102"), "{}", linkage.detail);
assert!(
linkage.detail.contains("--add-sub-issue 102"),
"{}",
linkage.detail
);
assert!(!linkage.detail.contains("#103"), "{}", linkage.detail);
}
#[test]
fn audit_rows_are_empty_for_a_non_tracker() {
let backend = FakeBackend::new();
backend.seed_tracker(7, "a plain issue", "prose with no markers\n");
let rows = audit_rows::epic_rows(&backend, 7, &status_model()).expect("rows");
assert!(rows.is_empty(), "{rows:?}");
assert_eq!(backend.calls("children"), 0);
assert_eq!(backend.calls("phase_titled_issues"), 0);
}
#[test]
fn audit_rows_fail_a_body_whose_markers_cannot_be_rewritten() {
let backend = FakeBackend::new();
let body = format!("{PHASES_START}\na\n{PHASES_START}\nb\n{PHASES_END}\n");
backend.seed_tracker(100, "[EPIC 100] An outcome", &body);
let rows = audit_rows::epic_rows(&backend, 100, &status_model()).expect("rows");
let block = row(&rows, REQ_PHASES_BLOCK);
assert_eq!(block.verdict, Verdict::Fail);
assert!(
block.detail.contains("exactly one is required"),
"{}",
block.detail
);
}
#[test]
fn gh_backend_reads_a_childs_labels_with_its_body() {
let runner = FakeRunner::new(vec![
ok_out(
r#"{"subIssues":{"nodes":[{"number":8447,"title":"[EPIC_8445 PHASE_1] a","state":"OPEN"}],"totalCount":1}}"#,
),
ok_out(
"{\"body\":\"## Gate\\n\\nnone\\n\",\"labels\":[{\"name\":\"status:coded\"},{\"name\":\"trusty-mpm\"}]}",
),
]);
let backend = super::backend::GhEpicBackend::new(runner);
let children = backend.children(8445).expect("parses");
assert_eq!(children[0].labels, vec!["status:coded", "trusty-mpm"]);
assert_eq!(render::state_cell(&children[0], &status_model()), "coded");
let calls = backend_calls(&backend);
assert!(calls[1].contains(&"body,labels".to_string()), "{calls:?}");
}
fn backend_calls(backend: &super::backend::GhEpicBackend<FakeRunner>) -> Vec<Vec<String>> {
backend.runner().calls.borrow().clone()
}
#[test]
fn gh_backend_reads_a_parent_and_its_absence() {
let backend = super::backend::GhEpicBackend::new(FakeRunner::new(vec![ok_out(
r#"{"parent":{"number":8445,"title":"[EPIC 8445] x"}}"#,
)]));
assert_eq!(backend.parent(8448).expect("reads"), Some(8445));
let backend =
super::backend::GhEpicBackend::new(FakeRunner::new(vec![ok_out(r#"{"parent":null}"#)]));
assert_eq!(backend.parent(8448).expect("reads"), None);
let backend = super::backend::GhEpicBackend::new(FakeRunner::new(vec![]));
assert!(backend.parent(8448).is_err());
}
#[test]
fn gh_backend_refuses_a_parent_read_with_no_parent_key() {
let backend =
super::backend::GhEpicBackend::new(FakeRunner::new(vec![ok_out(r#"{"number":8448}"#)]));
let err = backend.parent(8448).unwrap_err();
assert!(err.to_string().contains("parent of #8448"), "{err}");
}
#[test]
fn gh_backend_refuses_a_full_phase_title_page() {
let rows: Vec<String> = (1..=200)
.map(|n| format!(r#"{{"number":{n},"title":"[EPIC_9 PHASE_{n}] p"}}"#))
.collect();
let backend = super::backend::GhEpicBackend::new(FakeRunner::new(vec![ok_out(&format!(
"[{}]",
rows.join(",")
))]));
let err = backend.phase_titled_issues(9).unwrap_err();
assert!(err.to_string().contains("full page"), "{err}");
let backend = super::backend::GhEpicBackend::new(FakeRunner::new(vec![ok_out(
r#"[{"number":8447,"title":"[EPIC_8445 PHASE_1] a"}]"#,
)]));
let found = backend.phase_titled_issues(8445).expect("reads");
assert_eq!(found.len(), 1);
assert_eq!(found[0].number, 8447);
let calls = backend_calls(&backend);
assert!(
calls[0].contains(&"in:title \"EPIC_8445 PHASE_\"".to_string()),
"{calls:?}"
);
}