use std::collections::BTreeMap;
use std::fmt::Write as _;
use super::backend::EpicBackend;
use super::render;
pub(crate) const CLOSE_COMMENT_PREFIX: &str = "tm issue epic close:";
#[derive(Debug, Clone)]
pub(crate) struct CloseReport {
pub(crate) tracker: u64,
pub(crate) phases: Vec<u64>,
pub(crate) outcomes: usize,
pub(crate) comment_posted: bool,
}
pub(crate) fn close<B: EpicBackend>(
backend: &B,
tracker: u64,
evidence: &[String],
) -> anyhow::Result<CloseReport> {
let children = backend.children(tracker)?;
if children.is_empty() {
anyhow::bail!(
"#{tracker} has no native sub-issues — nothing to close as an epic. Link its phases \
with `gh issue edit {tracker} --add-sub-issue <number>` first, or close it by hand"
);
}
let open: Vec<String> = children
.iter()
.filter(|c| !c.state.eq_ignore_ascii_case("CLOSED"))
.map(|c| format!("#{} `{}`", c.number, c.title.trim()))
.collect();
if !open.is_empty() {
anyhow::bail!(
"#{tracker} still has {} open child issue(s): {} — close them first",
open.len(),
open.join(", ")
);
}
let body = backend.body(tracker)?;
let outcomes = render::outcomes_of(&body);
if outcomes.is_empty() {
anyhow::bail!(
"#{tracker}'s body declares no `- **O<n>** …` outcomes under `## Outcomes`, so the \
closing comment has nothing to map evidence to — add them, or close it by hand"
);
}
let evidence = evidence_by_outcome(evidence, &outcomes)?;
let mut phases: Vec<u64> = children.iter().map(|c| c.number).collect();
phases.sort_unstable();
let mut comment = format!(
"{CLOSE_COMMENT_PREFIX} every phase is closed — {}.\n",
phases
.iter()
.map(|n| format!("#{n}"))
.collect::<Vec<_>>()
.join(", ")
);
for (id, text) in &outcomes {
let proof = evidence
.get(id.as_str())
.ok_or_else(|| anyhow::anyhow!("internal: evidence for {id} vanished"))?;
let _ = write!(comment, "\n- **{id}** {text} — {proof}");
}
comment.push('\n');
let already = backend
.comments(tracker)?
.iter()
.any(|c| c.trim_start().starts_with(CLOSE_COMMENT_PREFIX));
if !already {
backend.comment(tracker, &comment)?;
}
backend
.close_issue(tracker)
.map_err(|e| anyhow::anyhow!("#{tracker}: the closing comment is posted but the issue is still open ({e}) — re-run to finish the close"))?;
Ok(CloseReport {
tracker,
phases,
outcomes: outcomes.len(),
comment_posted: !already,
})
}
fn evidence_by_outcome<'a>(
evidence: &'a [String],
outcomes: &[(String, String)],
) -> anyhow::Result<BTreeMap<&'a str, &'a str>> {
let mut by_id: BTreeMap<&str, &str> = BTreeMap::new();
for entry in evidence {
let trimmed = entry.trim();
let split = trimmed
.find([':', ' '])
.map(|i| (&trimmed[..i], trimmed[i + 1..].trim()));
let Some((id, text)) = split.filter(|(id, text)| !id.is_empty() && !text.is_empty()) else {
anyhow::bail!(
"`--evidence {entry:?}` is not `O<n>: <what proves it>` — one entry per outcome"
);
};
if !id.starts_with('O') || !id[1..].chars().all(|c| c.is_ascii_digit()) || id.len() < 2 {
anyhow::bail!("`--evidence {entry:?}` names `{id}`, which is not an `O<n>` outcome id");
}
if by_id.insert(id, text).is_some() {
anyhow::bail!("`--evidence` names {id} twice — one entry per outcome");
}
}
let declared: Vec<&str> = outcomes.iter().map(|(id, _)| id.as_str()).collect();
let missing: Vec<&str> = declared
.iter()
.copied()
.filter(|id| !by_id.contains_key(id))
.collect();
let unknown: Vec<&str> = by_id
.keys()
.copied()
.filter(|id| !declared.contains(id))
.collect();
if !missing.is_empty() || !unknown.is_empty() {
anyhow::bail!(
"the tracker declares outcomes [{}]; `--evidence` is missing [{}] and names unknown \
[{}] — pass exactly one `--evidence \"O<n>: <what proves it>\"` per declared outcome",
declared.join(", "),
missing.join(", "),
unknown.join(", ")
);
}
Ok(by_id)
}