mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-08-28 19:20:41 +08:00
Add archive-on-done: stories become the dated record when an epic finishes
- ticket_tree.py archive verb: a done/dropped epic's leaves move to .archive/<date>-<slug>/ (dot folders are off every verb's board); --purge deletes instead when the record of truth lives elsewhere (e.g. stories synced to Jira); satisfied depends_on edges into the archived set are dropped; the envelope stays live carrying the epic-level covers - Guards: stored done/dropped required; all leaves finished; live deps on non-done archived leaves refuse; sub-epics archive first - next-id scans dot folders so archived ids are never reissued - update_ticket returns an archive hint when done lands on an epic; the skill offers the archive — never automatic - write_index extracted for reuse; regression tests in both homes
This commit is contained in:
@@ -116,7 +116,7 @@ A loosely named target resolves in tiers: exact id/slug/title match (`ticket_tre
|
||||
|
||||
## Tree queries
|
||||
|
||||
Derived state is never hand-computed: `uv run {skill-root}/scripts/ticket_tree.py <verb> --root {workflow.tickets_output_path}` — `next-id` before allocating, `index` after any structural write, `validate` after every write (schema, placeholders, dep resolution, cycles — fix what it names before presenting), `list` for the id/title/status/path inventory, `frontier` for "what's workable now," `board` for rollups (computed epic state included), `coverage --require "<ids>"` for the coverage check (`--proposed` pre-gate, before anything is on disk), `graph --mermaid` for the dependency graph, parallel lanes, and critical path. When the user asks to optimize sequencing or dependencies — and as an offer after incept writes — render `graph --mermaid` and walk the lanes with them: false edges, over-serialized independents, the critical path.
|
||||
Derived state is never hand-computed: `uv run {skill-root}/scripts/ticket_tree.py <verb> --root {workflow.tickets_output_path}` — `next-id` before allocating, `index` after any structural write, `validate` after every write (schema, placeholders, dep resolution, cycles — fix what it names before presenting), `list` for the id/title/status/path inventory, `frontier` for "what's workable now," `board` for rollups (computed epic state included), `coverage --require "<ids>"` for the coverage check (`--proposed` pre-gate, before anything is on disk), `graph --mermaid` for the dependency graph, parallel lanes, and critical path. When the user asks to optimize sequencing or dependencies — and as an offer after incept writes — render `graph --mermaid` and walk the lanes with them: false edges, over-serialized independents, the critical path. When `done` lands on an epic (the gate returns the hint), offer the archive: `archive --epic KEY-n` moves its stories to the dated `.archive/` record — the envelope stays as the durable layer — or `--purge` removes them when the record of truth lives elsewhere (e.g. synced to Jira). An offer, never automatic.
|
||||
References: `slice-epics.md` (Route 2) · `incept-stories.md` (Route 3) · `greenfield-guidelines.md` (net-new project, at epic proposal) · `v6-migration.md` (v6 shapes + migration) · type templates in `assets/` via `{workflow.<type>_template}`.
|
||||
|
||||
Run `{workflow.on_complete}` if set when we reach a terminal state.
|
||||
|
||||
@@ -267,6 +267,85 @@ class TicketTreeTests(unittest.TestCase):
|
||||
self.assertEqual(len(out["lanes"]), n)
|
||||
self.assertEqual(len(out["critical_path"]), n)
|
||||
|
||||
def _finish_alert_rules(self):
|
||||
(self.root / "alert-rules" / "ticket.md").write_text(ticket(
|
||||
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
|
||||
extra='description: "Rules people manage"\nstatus: done\n'))
|
||||
(self.root / "alert-rules" / "ALRT-13-rule-eval.md").write_text(
|
||||
ticket("ALRT-13", "story", "Rule eval", status="done",
|
||||
deps="[ALRT-12]", covers="[CAP-5]"))
|
||||
|
||||
def test_archive_moves_leaves_and_drops_satisfied_edges(self):
|
||||
self._finish_alert_rules()
|
||||
(self.root / "ALRT-45-followup.md").write_text(
|
||||
ticket("ALRT-45", "task", "Follow-up", deps="[ALRT-12, ALRT-40]"))
|
||||
code, out = run("archive", "--root", str(self.root),
|
||||
"--epic", "ALRT-3", "--date", "2026-08-02")
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertEqual(out["archived"], ["ALRT-12", "ALRT-13"])
|
||||
dest = self.root / ".archive" / "2026-08-02-alert-rules"
|
||||
self.assertTrue((dest / "ALRT-12-rule-crud.md").is_file())
|
||||
self.assertFalse((self.root / "alert-rules" / "ALRT-12-rule-crud.md").exists())
|
||||
self.assertTrue((self.root / "alert-rules" / "ticket.md").is_file()) # envelope stays
|
||||
# satisfied edge into the archive dropped; unrelated edge kept
|
||||
self.assertIn("depends_on: [ALRT-40]",
|
||||
(self.root / "ALRT-45-followup.md").read_text())
|
||||
# archived leaves are off the board everywhere
|
||||
code, out = run("list", "--root", str(self.root))
|
||||
ids = [r["id"] for r in out["tickets"]]
|
||||
self.assertNotIn("ALRT-12", ids)
|
||||
self.assertIn("ALRT-3", ids)
|
||||
code, out = run("validate", "--root", str(self.root))
|
||||
self.assertEqual(code, 0, out)
|
||||
|
||||
def test_archive_refuses_unfinished(self):
|
||||
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("not marked done", out["error"])
|
||||
(self.root / "alert-rules" / "ticket.md").write_text(ticket(
|
||||
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
|
||||
extra='description: "Rules people manage"\nstatus: done\n'))
|
||||
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
|
||||
self.assertEqual(code, 1) # ALRT-13 still backlog
|
||||
self.assertIn("still open", out["error"])
|
||||
self.assertIn("ALRT-13", out["error"])
|
||||
|
||||
def test_archive_refuses_dep_on_dropped_leaf(self):
|
||||
self._finish_alert_rules()
|
||||
(self.root / "alert-rules" / "ALRT-13-rule-eval.md").write_text(
|
||||
ticket("ALRT-13", "story", "Rule eval", status="dropped"))
|
||||
(self.root / "ALRT-46-waiting.md").write_text(
|
||||
ticket("ALRT-46", "task", "Waiting", deps="[ALRT-13]"))
|
||||
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("dropped", out["error"])
|
||||
self.assertIn("ALRT-46", out["error"])
|
||||
|
||||
def test_archive_purge_deletes(self):
|
||||
self._finish_alert_rules()
|
||||
code, out = run("archive", "--root", str(self.root),
|
||||
"--epic", "ALRT-3", "--purge")
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertEqual(out["purged"], ["ALRT-12", "ALRT-13"])
|
||||
self.assertIsNone(out["dest"])
|
||||
self.assertFalse((self.root / ".archive").exists())
|
||||
self.assertFalse((self.root / "alert-rules" / "ALRT-12-rule-crud.md").exists())
|
||||
|
||||
def test_archived_ids_never_reissued(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root = Path(d)
|
||||
(root / "index.md").write_text("---\nkey: NEW\n---\n")
|
||||
e = root / "one"
|
||||
e.mkdir()
|
||||
(e / "ticket.md").write_text(ticket(
|
||||
"NEW-1", "epic", "One", extra='description: "x"\nstatus: done\n'))
|
||||
(e / "NEW-2-only.md").write_text(
|
||||
ticket("NEW-2", "task", "Only", status="done"))
|
||||
code, out = run("archive", "--root", str(root), "--epic", "NEW-1")
|
||||
self.assertEqual(code, 0, out)
|
||||
code, out = run("next-id", "--root", str(root))
|
||||
self.assertEqual(out["id"], "NEW-3") # NEW-2 lives in .archive, still owns its id
|
||||
|
||||
def test_render_monofile_view(self):
|
||||
out_file = self.root / "epics-and-stories.md"
|
||||
code, out = run("render", "--root", str(self.root), "--out", str(out_file))
|
||||
|
||||
@@ -115,6 +115,7 @@ class UpdateTicketTests(unittest.TestCase):
|
||||
code, out = run("--path", str(self.epic), "--set", "status=done")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("status: done", self.epic.read_text())
|
||||
self.assertIn("archive", out.get("hint", "")) # done → archive offer surfaces
|
||||
code, out = run("--path", str(self.epic), "--set", "status=dropped")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("status: dropped", self.epic.read_text())
|
||||
|
||||
@@ -17,6 +17,9 @@ Everything here is derived by scan — nothing is stored. Verbs:
|
||||
coverage covers: vs an inventory uv run ticket_tree.py coverage --root R [--require "CAP-1,FR-2"] [--proposed "CAP-3"]
|
||||
render single epics-and-stories uv run ticket_tree.py render --root R --out FILE
|
||||
markdown view (generated; the tree stays the source of truth)
|
||||
archive move a done epic's leaves uv run ticket_tree.py archive --root R --epic KEY-n [--purge] [--date YYYY-MM-DD]
|
||||
to .archive/<date>-<slug>/ as the dated record (--purge deletes
|
||||
instead, for stories whose record lives elsewhere, e.g. Jira)
|
||||
|
||||
Output is one JSON object per call. Stdlib only. Dual-homed: canonical at
|
||||
src/scripts/ (installed to {project-root}/_bmad/scripts/ for any skill or
|
||||
@@ -25,6 +28,7 @@ Keep both in sync.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -94,16 +98,20 @@ def parse_frontmatter(text):
|
||||
return None
|
||||
|
||||
|
||||
def scan(root):
|
||||
def scan(root, include_dot=False):
|
||||
"""Return (tickets, by_id, problems). Each ticket: frontmatter + _path + _rel.
|
||||
problems: files that claim to be tickets but can't be trusted — unreadable,
|
||||
unparseable frontmatter, missing id/type, or a duplicate id (first file wins)."""
|
||||
unparseable frontmatter, missing id/type, or a duplicate id (first file wins).
|
||||
Dot folders (.archive/, .git/) are off the board unless include_dot."""
|
||||
tickets = []
|
||||
by_id = {}
|
||||
problems = []
|
||||
for p in sorted(root.rglob("*.md")):
|
||||
if p.name == "index.md":
|
||||
continue
|
||||
rel_parts = p.relative_to(root).parts
|
||||
if not include_dot and any(part.startswith(".") for part in rel_parts):
|
||||
continue
|
||||
rel = p.relative_to(root).as_posix()
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8")
|
||||
@@ -216,7 +224,8 @@ def id_num(ticket_id):
|
||||
|
||||
def cmd_next_id(root, args):
|
||||
key = resolve_key(root, args.key)
|
||||
tickets, _, problems = scan(root)
|
||||
# include_dot: archived tickets still own their ids — never reissue them.
|
||||
tickets, _, problems = scan(root, include_dot=True)
|
||||
nums = [id_num(t["id"]) for t in tickets
|
||||
if re.fullmatch(re.escape(key) + r"-\d+", str(t["id"]), re.IGNORECASE)]
|
||||
nxt = (max(nums) + 1) if nums else 1
|
||||
@@ -226,8 +235,7 @@ def cmd_next_id(root, args):
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
def cmd_index(root, args):
|
||||
key = resolve_key(root, args.key)
|
||||
def write_index(root, key):
|
||||
tickets, _, problems = scan(root)
|
||||
|
||||
def entry_lines(t, depth):
|
||||
@@ -253,7 +261,13 @@ def cmd_index(root, args):
|
||||
body.extend(entry_lines(lf, 0))
|
||||
body.append("")
|
||||
atomic_write(root / "index.md", "\n".join(body))
|
||||
out = {"ok": True, "file": str(root / "index.md"), "entries": len(tickets)}
|
||||
return len(tickets), problems
|
||||
|
||||
|
||||
def cmd_index(root, args):
|
||||
key = resolve_key(root, args.key)
|
||||
entries, problems = write_index(root, key)
|
||||
out = {"ok": True, "file": str(root / "index.md"), "entries": entries}
|
||||
if problems:
|
||||
out["warnings"] = problems
|
||||
print(json.dumps(out))
|
||||
@@ -456,6 +470,96 @@ def cmd_render(root, args):
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
def _rewrite_depends_on(path, new_deps):
|
||||
"""Replace a ticket's depends_on entry (inline or block form) with an
|
||||
inline list. Only used by archive to drop satisfied edges."""
|
||||
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
new_line = "depends_on: [" + ", ".join(str(d) for d in new_deps) + "]\n"
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^depends_on:", line):
|
||||
j = i + 1
|
||||
if line.split(":", 1)[1].strip() == "":
|
||||
while j < len(lines) and re.match(r"^\s+-\s+", lines[j]):
|
||||
j += 1
|
||||
lines[i:j] = [new_line]
|
||||
break
|
||||
atomic_write(path, "".join(lines))
|
||||
|
||||
|
||||
def cmd_archive(root, args):
|
||||
"""Move a finished epic's leaves to .archive/<date>-<slug>/ — the dated,
|
||||
off-board record of a moment. --purge deletes instead, for stories whose
|
||||
record of truth lives elsewhere (e.g. synced to Jira). The envelope stays:
|
||||
it is the durable thin layer carrying the epic-level covers."""
|
||||
tickets, by_id, problems = scan(root)
|
||||
epic = by_id.get(args.epic)
|
||||
if epic is None or epic.get("type") != "epic":
|
||||
fail(f"no epic with id '{args.epic}' in the tree")
|
||||
if epic.get("status") not in (DONE, DROPPED):
|
||||
fail(f"epic {args.epic} is not marked done or dropped — archive is for "
|
||||
"finished records; store the intentional status first (update_ticket.py)")
|
||||
kids = children_of(epic, tickets)
|
||||
if any(k["type"] == "epic" for k in kids):
|
||||
fail(f"epic {args.epic} contains sub-epics — archive those first")
|
||||
leaves = [k for k in kids if k["type"] in LEAF_TYPES]
|
||||
if not leaves:
|
||||
fail(f"epic {args.epic} has no leaves to archive")
|
||||
still_open = sorted(str(k["id"]) for k in leaves
|
||||
if k.get("status") not in (DONE, DROPPED))
|
||||
if still_open:
|
||||
fail("archive is for finished records — still open under "
|
||||
f"{args.epic}: {', '.join(still_open)}")
|
||||
leaf_ids = {str(k["id"]): k for k in leaves}
|
||||
done_ids = {i for i, k in leaf_ids.items() if k.get("status") == DONE}
|
||||
|
||||
blockers, edge_drops = [], {}
|
||||
for t in tickets:
|
||||
tid = str(t["id"])
|
||||
if tid in leaf_ids or t is epic:
|
||||
continue
|
||||
refs = [str(d) for d in as_list(t, "depends_on") if str(d) in leaf_ids]
|
||||
if not refs:
|
||||
continue
|
||||
undone = [r for r in refs if r not in done_ids]
|
||||
if undone:
|
||||
blockers.append(f"{tid} depends on dropped {', '.join(undone)}")
|
||||
else:
|
||||
edge_drops[tid] = refs
|
||||
if blockers:
|
||||
fail("live tickets depend on non-done leaves in the archive set — resolve "
|
||||
"these edges first: " + "; ".join(blockers))
|
||||
|
||||
date = args.date or datetime.date.today().isoformat()
|
||||
if not DATE_RE.match(date):
|
||||
fail(f"--date must be YYYY-MM-DD, got '{date}'")
|
||||
|
||||
dest = None
|
||||
if args.purge:
|
||||
for k in leaves:
|
||||
k["_path"].unlink()
|
||||
else:
|
||||
dest = root / ".archive" / f"{date}-{epic['_path'].parent.name}"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for k in leaves:
|
||||
os.replace(k["_path"], dest / k["_path"].name)
|
||||
|
||||
for tid, refs in edge_drops.items():
|
||||
t = by_id[tid]
|
||||
kept = [str(d) for d in as_list(t, "depends_on") if str(d) not in refs]
|
||||
_rewrite_depends_on(t["_path"], kept)
|
||||
|
||||
key = read_index_key(root)
|
||||
if key:
|
||||
write_index(root, key)
|
||||
out = {"ok": True, "epic": args.epic,
|
||||
("purged" if args.purge else "archived"): sorted(leaf_ids),
|
||||
"dest": str(dest) if dest else None,
|
||||
"edges_dropped": edge_drops}
|
||||
if problems:
|
||||
out["warnings"] = problems
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
def _placeholderish(v):
|
||||
return isinstance(v, str) and ("[" in v or "]" in v or "YYYY" in v)
|
||||
|
||||
@@ -586,7 +690,7 @@ def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
sub = ap.add_subparsers(dest="verb", required=True)
|
||||
for verb in ("next-id", "index", "validate", "list", "frontier", "board",
|
||||
"coverage", "graph", "render"):
|
||||
"coverage", "graph", "render", "archive"):
|
||||
p = sub.add_parser(verb)
|
||||
p.add_argument("--root", required=True, help="ticket tree root")
|
||||
if verb in ("next-id", "index"):
|
||||
@@ -602,6 +706,11 @@ def main():
|
||||
if verb == "render":
|
||||
p.add_argument("--out", required=True,
|
||||
help="path for the generated epics-and-stories markdown")
|
||||
if verb == "archive":
|
||||
p.add_argument("--epic", required=True, help="id of the done/dropped epic")
|
||||
p.add_argument("--purge", action="store_true",
|
||||
help="delete instead of moving (record lives elsewhere, e.g. Jira)")
|
||||
p.add_argument("--date", help="archive folder date, defaults to today")
|
||||
args = ap.parse_args()
|
||||
root = Path(args.root)
|
||||
if not root.is_dir():
|
||||
@@ -610,7 +719,7 @@ def main():
|
||||
{"next-id": cmd_next_id, "index": cmd_index, "validate": cmd_validate,
|
||||
"list": cmd_list, "frontier": cmd_frontier, "board": cmd_board,
|
||||
"coverage": cmd_coverage, "graph": cmd_graph,
|
||||
"render": cmd_render}[args.verb](root, args)
|
||||
"render": cmd_render, "archive": cmd_archive}[args.verb](root, args)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # keep the JSON output contract on fs/encoding errors
|
||||
|
||||
@@ -387,7 +387,12 @@ def main():
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
print(json.dumps({"ok": True, "file": str(path), "changes": changes, "unchanged": unchanged}))
|
||||
out = {"ok": True, "file": str(path), "changes": changes, "unchanged": unchanged}
|
||||
if ticket_type == "epic" and changes.get("status", {}).get("to") == "done":
|
||||
out["hint"] = ("epic marked done — offer to archive its stories to the dated "
|
||||
"record: ticket_tree.py archive --epic <id> (--purge if the "
|
||||
"record lives elsewhere, e.g. Jira)")
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -267,6 +267,85 @@ class TicketTreeTests(unittest.TestCase):
|
||||
self.assertEqual(len(out["lanes"]), n)
|
||||
self.assertEqual(len(out["critical_path"]), n)
|
||||
|
||||
def _finish_alert_rules(self):
|
||||
(self.root / "alert-rules" / "ticket.md").write_text(ticket(
|
||||
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
|
||||
extra='description: "Rules people manage"\nstatus: done\n'))
|
||||
(self.root / "alert-rules" / "ALRT-13-rule-eval.md").write_text(
|
||||
ticket("ALRT-13", "story", "Rule eval", status="done",
|
||||
deps="[ALRT-12]", covers="[CAP-5]"))
|
||||
|
||||
def test_archive_moves_leaves_and_drops_satisfied_edges(self):
|
||||
self._finish_alert_rules()
|
||||
(self.root / "ALRT-45-followup.md").write_text(
|
||||
ticket("ALRT-45", "task", "Follow-up", deps="[ALRT-12, ALRT-40]"))
|
||||
code, out = run("archive", "--root", str(self.root),
|
||||
"--epic", "ALRT-3", "--date", "2026-08-02")
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertEqual(out["archived"], ["ALRT-12", "ALRT-13"])
|
||||
dest = self.root / ".archive" / "2026-08-02-alert-rules"
|
||||
self.assertTrue((dest / "ALRT-12-rule-crud.md").is_file())
|
||||
self.assertFalse((self.root / "alert-rules" / "ALRT-12-rule-crud.md").exists())
|
||||
self.assertTrue((self.root / "alert-rules" / "ticket.md").is_file()) # envelope stays
|
||||
# satisfied edge into the archive dropped; unrelated edge kept
|
||||
self.assertIn("depends_on: [ALRT-40]",
|
||||
(self.root / "ALRT-45-followup.md").read_text())
|
||||
# archived leaves are off the board everywhere
|
||||
code, out = run("list", "--root", str(self.root))
|
||||
ids = [r["id"] for r in out["tickets"]]
|
||||
self.assertNotIn("ALRT-12", ids)
|
||||
self.assertIn("ALRT-3", ids)
|
||||
code, out = run("validate", "--root", str(self.root))
|
||||
self.assertEqual(code, 0, out)
|
||||
|
||||
def test_archive_refuses_unfinished(self):
|
||||
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("not marked done", out["error"])
|
||||
(self.root / "alert-rules" / "ticket.md").write_text(ticket(
|
||||
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
|
||||
extra='description: "Rules people manage"\nstatus: done\n'))
|
||||
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
|
||||
self.assertEqual(code, 1) # ALRT-13 still backlog
|
||||
self.assertIn("still open", out["error"])
|
||||
self.assertIn("ALRT-13", out["error"])
|
||||
|
||||
def test_archive_refuses_dep_on_dropped_leaf(self):
|
||||
self._finish_alert_rules()
|
||||
(self.root / "alert-rules" / "ALRT-13-rule-eval.md").write_text(
|
||||
ticket("ALRT-13", "story", "Rule eval", status="dropped"))
|
||||
(self.root / "ALRT-46-waiting.md").write_text(
|
||||
ticket("ALRT-46", "task", "Waiting", deps="[ALRT-13]"))
|
||||
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("dropped", out["error"])
|
||||
self.assertIn("ALRT-46", out["error"])
|
||||
|
||||
def test_archive_purge_deletes(self):
|
||||
self._finish_alert_rules()
|
||||
code, out = run("archive", "--root", str(self.root),
|
||||
"--epic", "ALRT-3", "--purge")
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertEqual(out["purged"], ["ALRT-12", "ALRT-13"])
|
||||
self.assertIsNone(out["dest"])
|
||||
self.assertFalse((self.root / ".archive").exists())
|
||||
self.assertFalse((self.root / "alert-rules" / "ALRT-12-rule-crud.md").exists())
|
||||
|
||||
def test_archived_ids_never_reissued(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root = Path(d)
|
||||
(root / "index.md").write_text("---\nkey: NEW\n---\n")
|
||||
e = root / "one"
|
||||
e.mkdir()
|
||||
(e / "ticket.md").write_text(ticket(
|
||||
"NEW-1", "epic", "One", extra='description: "x"\nstatus: done\n'))
|
||||
(e / "NEW-2-only.md").write_text(
|
||||
ticket("NEW-2", "task", "Only", status="done"))
|
||||
code, out = run("archive", "--root", str(root), "--epic", "NEW-1")
|
||||
self.assertEqual(code, 0, out)
|
||||
code, out = run("next-id", "--root", str(root))
|
||||
self.assertEqual(out["id"], "NEW-3") # NEW-2 lives in .archive, still owns its id
|
||||
|
||||
def test_render_monofile_view(self):
|
||||
out_file = self.root / "epics-and-stories.md"
|
||||
code, out = run("render", "--root", str(self.root), "--out", str(out_file))
|
||||
|
||||
@@ -115,6 +115,7 @@ class UpdateTicketTests(unittest.TestCase):
|
||||
code, out = run("--path", str(self.epic), "--set", "status=done")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("status: done", self.epic.read_text())
|
||||
self.assertIn("archive", out.get("hint", "")) # done → archive offer surfaces
|
||||
code, out = run("--path", str(self.epic), "--set", "status=dropped")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("status: dropped", self.epic.read_text())
|
||||
|
||||
+117
-8
@@ -17,6 +17,9 @@ Everything here is derived by scan — nothing is stored. Verbs:
|
||||
coverage covers: vs an inventory uv run ticket_tree.py coverage --root R [--require "CAP-1,FR-2"] [--proposed "CAP-3"]
|
||||
render single epics-and-stories uv run ticket_tree.py render --root R --out FILE
|
||||
markdown view (generated; the tree stays the source of truth)
|
||||
archive move a done epic's leaves uv run ticket_tree.py archive --root R --epic KEY-n [--purge] [--date YYYY-MM-DD]
|
||||
to .archive/<date>-<slug>/ as the dated record (--purge deletes
|
||||
instead, for stories whose record lives elsewhere, e.g. Jira)
|
||||
|
||||
Output is one JSON object per call. Stdlib only. Dual-homed: canonical at
|
||||
src/scripts/ (installed to {project-root}/_bmad/scripts/ for any skill or
|
||||
@@ -25,6 +28,7 @@ Keep both in sync.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -94,16 +98,20 @@ def parse_frontmatter(text):
|
||||
return None
|
||||
|
||||
|
||||
def scan(root):
|
||||
def scan(root, include_dot=False):
|
||||
"""Return (tickets, by_id, problems). Each ticket: frontmatter + _path + _rel.
|
||||
problems: files that claim to be tickets but can't be trusted — unreadable,
|
||||
unparseable frontmatter, missing id/type, or a duplicate id (first file wins)."""
|
||||
unparseable frontmatter, missing id/type, or a duplicate id (first file wins).
|
||||
Dot folders (.archive/, .git/) are off the board unless include_dot."""
|
||||
tickets = []
|
||||
by_id = {}
|
||||
problems = []
|
||||
for p in sorted(root.rglob("*.md")):
|
||||
if p.name == "index.md":
|
||||
continue
|
||||
rel_parts = p.relative_to(root).parts
|
||||
if not include_dot and any(part.startswith(".") for part in rel_parts):
|
||||
continue
|
||||
rel = p.relative_to(root).as_posix()
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8")
|
||||
@@ -216,7 +224,8 @@ def id_num(ticket_id):
|
||||
|
||||
def cmd_next_id(root, args):
|
||||
key = resolve_key(root, args.key)
|
||||
tickets, _, problems = scan(root)
|
||||
# include_dot: archived tickets still own their ids — never reissue them.
|
||||
tickets, _, problems = scan(root, include_dot=True)
|
||||
nums = [id_num(t["id"]) for t in tickets
|
||||
if re.fullmatch(re.escape(key) + r"-\d+", str(t["id"]), re.IGNORECASE)]
|
||||
nxt = (max(nums) + 1) if nums else 1
|
||||
@@ -226,8 +235,7 @@ def cmd_next_id(root, args):
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
def cmd_index(root, args):
|
||||
key = resolve_key(root, args.key)
|
||||
def write_index(root, key):
|
||||
tickets, _, problems = scan(root)
|
||||
|
||||
def entry_lines(t, depth):
|
||||
@@ -253,7 +261,13 @@ def cmd_index(root, args):
|
||||
body.extend(entry_lines(lf, 0))
|
||||
body.append("")
|
||||
atomic_write(root / "index.md", "\n".join(body))
|
||||
out = {"ok": True, "file": str(root / "index.md"), "entries": len(tickets)}
|
||||
return len(tickets), problems
|
||||
|
||||
|
||||
def cmd_index(root, args):
|
||||
key = resolve_key(root, args.key)
|
||||
entries, problems = write_index(root, key)
|
||||
out = {"ok": True, "file": str(root / "index.md"), "entries": entries}
|
||||
if problems:
|
||||
out["warnings"] = problems
|
||||
print(json.dumps(out))
|
||||
@@ -456,6 +470,96 @@ def cmd_render(root, args):
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
def _rewrite_depends_on(path, new_deps):
|
||||
"""Replace a ticket's depends_on entry (inline or block form) with an
|
||||
inline list. Only used by archive to drop satisfied edges."""
|
||||
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
new_line = "depends_on: [" + ", ".join(str(d) for d in new_deps) + "]\n"
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^depends_on:", line):
|
||||
j = i + 1
|
||||
if line.split(":", 1)[1].strip() == "":
|
||||
while j < len(lines) and re.match(r"^\s+-\s+", lines[j]):
|
||||
j += 1
|
||||
lines[i:j] = [new_line]
|
||||
break
|
||||
atomic_write(path, "".join(lines))
|
||||
|
||||
|
||||
def cmd_archive(root, args):
|
||||
"""Move a finished epic's leaves to .archive/<date>-<slug>/ — the dated,
|
||||
off-board record of a moment. --purge deletes instead, for stories whose
|
||||
record of truth lives elsewhere (e.g. synced to Jira). The envelope stays:
|
||||
it is the durable thin layer carrying the epic-level covers."""
|
||||
tickets, by_id, problems = scan(root)
|
||||
epic = by_id.get(args.epic)
|
||||
if epic is None or epic.get("type") != "epic":
|
||||
fail(f"no epic with id '{args.epic}' in the tree")
|
||||
if epic.get("status") not in (DONE, DROPPED):
|
||||
fail(f"epic {args.epic} is not marked done or dropped — archive is for "
|
||||
"finished records; store the intentional status first (update_ticket.py)")
|
||||
kids = children_of(epic, tickets)
|
||||
if any(k["type"] == "epic" for k in kids):
|
||||
fail(f"epic {args.epic} contains sub-epics — archive those first")
|
||||
leaves = [k for k in kids if k["type"] in LEAF_TYPES]
|
||||
if not leaves:
|
||||
fail(f"epic {args.epic} has no leaves to archive")
|
||||
still_open = sorted(str(k["id"]) for k in leaves
|
||||
if k.get("status") not in (DONE, DROPPED))
|
||||
if still_open:
|
||||
fail("archive is for finished records — still open under "
|
||||
f"{args.epic}: {', '.join(still_open)}")
|
||||
leaf_ids = {str(k["id"]): k for k in leaves}
|
||||
done_ids = {i for i, k in leaf_ids.items() if k.get("status") == DONE}
|
||||
|
||||
blockers, edge_drops = [], {}
|
||||
for t in tickets:
|
||||
tid = str(t["id"])
|
||||
if tid in leaf_ids or t is epic:
|
||||
continue
|
||||
refs = [str(d) for d in as_list(t, "depends_on") if str(d) in leaf_ids]
|
||||
if not refs:
|
||||
continue
|
||||
undone = [r for r in refs if r not in done_ids]
|
||||
if undone:
|
||||
blockers.append(f"{tid} depends on dropped {', '.join(undone)}")
|
||||
else:
|
||||
edge_drops[tid] = refs
|
||||
if blockers:
|
||||
fail("live tickets depend on non-done leaves in the archive set — resolve "
|
||||
"these edges first: " + "; ".join(blockers))
|
||||
|
||||
date = args.date or datetime.date.today().isoformat()
|
||||
if not DATE_RE.match(date):
|
||||
fail(f"--date must be YYYY-MM-DD, got '{date}'")
|
||||
|
||||
dest = None
|
||||
if args.purge:
|
||||
for k in leaves:
|
||||
k["_path"].unlink()
|
||||
else:
|
||||
dest = root / ".archive" / f"{date}-{epic['_path'].parent.name}"
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for k in leaves:
|
||||
os.replace(k["_path"], dest / k["_path"].name)
|
||||
|
||||
for tid, refs in edge_drops.items():
|
||||
t = by_id[tid]
|
||||
kept = [str(d) for d in as_list(t, "depends_on") if str(d) not in refs]
|
||||
_rewrite_depends_on(t["_path"], kept)
|
||||
|
||||
key = read_index_key(root)
|
||||
if key:
|
||||
write_index(root, key)
|
||||
out = {"ok": True, "epic": args.epic,
|
||||
("purged" if args.purge else "archived"): sorted(leaf_ids),
|
||||
"dest": str(dest) if dest else None,
|
||||
"edges_dropped": edge_drops}
|
||||
if problems:
|
||||
out["warnings"] = problems
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
def _placeholderish(v):
|
||||
return isinstance(v, str) and ("[" in v or "]" in v or "YYYY" in v)
|
||||
|
||||
@@ -586,7 +690,7 @@ def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
sub = ap.add_subparsers(dest="verb", required=True)
|
||||
for verb in ("next-id", "index", "validate", "list", "frontier", "board",
|
||||
"coverage", "graph", "render"):
|
||||
"coverage", "graph", "render", "archive"):
|
||||
p = sub.add_parser(verb)
|
||||
p.add_argument("--root", required=True, help="ticket tree root")
|
||||
if verb in ("next-id", "index"):
|
||||
@@ -602,6 +706,11 @@ def main():
|
||||
if verb == "render":
|
||||
p.add_argument("--out", required=True,
|
||||
help="path for the generated epics-and-stories markdown")
|
||||
if verb == "archive":
|
||||
p.add_argument("--epic", required=True, help="id of the done/dropped epic")
|
||||
p.add_argument("--purge", action="store_true",
|
||||
help="delete instead of moving (record lives elsewhere, e.g. Jira)")
|
||||
p.add_argument("--date", help="archive folder date, defaults to today")
|
||||
args = ap.parse_args()
|
||||
root = Path(args.root)
|
||||
if not root.is_dir():
|
||||
@@ -610,7 +719,7 @@ def main():
|
||||
{"next-id": cmd_next_id, "index": cmd_index, "validate": cmd_validate,
|
||||
"list": cmd_list, "frontier": cmd_frontier, "board": cmd_board,
|
||||
"coverage": cmd_coverage, "graph": cmd_graph,
|
||||
"render": cmd_render}[args.verb](root, args)
|
||||
"render": cmd_render, "archive": cmd_archive}[args.verb](root, args)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # keep the JSON output contract on fs/encoding errors
|
||||
|
||||
@@ -387,7 +387,12 @@ def main():
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
print(json.dumps({"ok": True, "file": str(path), "changes": changes, "unchanged": unchanged}))
|
||||
out = {"ok": True, "file": str(path), "changes": changes, "unchanged": unchanged}
|
||||
if ticket_type == "epic" and changes.get("status", {}).get("to") == "done":
|
||||
out["hint"] = ("epic marked done — offer to archive its stories to the dated "
|
||||
"record: ticket_tree.py archive --epic <id> (--purge if the "
|
||||
"record lives elsewhere, e.g. Jira)")
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user