Skills › BMAD Method › bmad-agent-po › tickets.py
tickets.py
1885 lines · skills/bmad-agent-po/scripts/tickets.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""tickets — read a ticket tree and answer what is next.
7The tree's layout, the plan file, who writes each `status`, and how it becomes a board `state` are in
8the repo's `tools/ticket-tree-rules.md`; the keys of an entry, an epic, and an initiative table, and the forms `after`
9takes, are in the skill's `assets/tickets-template.toml`. Each command's `--help` says what it reads
10and returns.
12An initiative or epic folder keeps its own file at the top and its tickets in its `tickets/` folder:
13the `tickets.toml`, an initiative's epic folders, and the leaf files and plans. A backlog folder has no
14file of its own and holds its leaf files and plans directly. A tree with a `tickets.toml`, an epic
15folder, or a leaf file beside the container's own file is in the earlier layout and is refused; `bmad
16migrate` moves it. A parent initiative's `tickets.toml` lists `[[initiative]]` tables, its children,
17which are initiative folders beside it; `status` and `next` report them, and the other commands want
18one of the children.
20What this script decides itself: a leaf file joins its entry through the `id` in its frontmatter (`6`
21and `"6"` are one id); a plan joins through `ticket`, an entry id or a backlog file's stem, and is
22never a row. A leaf file's `after` and `hitl` replace the entry's, absent reading as empty; a
23difference is `drift` on the row. A prerequisite is met when it is done or in review, a dropped one
24still blocks, and an epic file's own `after` waits for that epic to be done. A plan whose `ticket`
25names nothing is skipped and one whose `status` is unknown blocks its ticket; both are listed under
26`problems`. A moved entry takes the destination's next id and goes last there; the folder it left keeps
27that id from coming back, and every `after` naming it is rewritten.
29The project root is `--project-root`, else the first folder at or above the working directory that
30holds `_bmad/`; `output_folder` and `active_initiative` come from the merged BMad config. A relative
31`<dir>` that is not under the working directory is looked up under `{output_folder}`, then the
32active initiative and its `tickets/` folder, then the project root. A `tickets/` folder given as
33`<dir>` is read as the folder that holds it.
35Output is one JSON object on stdout. Exit 0 on success, 1 on a malformed tree, 2 when the store
36forbids the operation.
37"""
39import argparse
40import codecs
41import importlib.util
42import json
43import os
44import re
45import sys
46import tomllib
47import unicodedata
48from datetime import date
49from pathlib import Path
51sys.dont_write_bytecode = True
53STATUSES = ("draft", "ready-for-dev", "in-progress", "in-review", "built", "done", "blocked", "dropped")
54STATES = ("backlog", "in-progress", "review", "done", "dropped")
55CONTAINER_STATUSES = ("in-progress", "done", "dropped")
56STATE_OF = {
57 "": "backlog",
58 "draft": "backlog",
59 "ready-for-dev": "backlog",
60 "in-progress": "in-progress",
61 "blocked": "in-progress",
62 "in-review": "review",
63 "built": "review",
64 "done": "done",
65 "dropped": "dropped",
66}
67LEAF_TYPES = ("story", "spike", "bug")
68CONTAINER_TYPES = ("initiative", "epic")
69NAME_RE = re.compile(r"^(story|spike|bug)-(.+)\.md$")
70ID_RE = re.compile(r"^[0-9A-Za-z]+$")
71CROSS_RE = re.compile(r"^([0-9A-Za-z]+)\.([0-9A-Za-z]+)$")
72EPIC_RE = re.compile(r"^epic-[^/]+$")
73BREAKDOWN = "tickets.toml"
74TICKETS = "tickets"
75TABLES = ("initiative", "epic", "entry")
76QUOTED_COMMENT_RE = re.compile(r"""^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*')\s+#.*$""")
77FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---(?:\n|\Z)", re.S)
78PLAN_FIELDS = ("status", "assignee", "blocked_at", "blocked_reason")
79COMMENT_RE = re.compile(r"<!--.*?-->", re.S) # a template's example sits in one
80UNKNOWN_RE = re.compile(r"^[ \t]*(?:[-*][ \t]+)?Unknown:[ \t]*(\S.*)$", re.M)
83class TicketError(Exception):
84 """`data` is added to the error object the command prints."""
86 def __init__(self, message: str, **data):
87 super().__init__(message)
88 self.data = data
91class NoMatch(TicketError):
92 pass
95class StoreRefusal(Exception):
96 pass
99# ---------------------------------------------------------------- frontmatter
102def parse_frontmatter(text: str, lenient: bool = False) -> dict:
103 """Minimal YAML subset: `key: value`, lists as `[a, b]`, quoted or bare scalars. Lenient
104 skips block lists instead of refusing them, for plans written from the build's template."""
105 m = FRONTMATTER_RE.match(text)
106 if not m:
107 return {}
108 data = {}
109 for line in m.group(1).splitlines():
110 if lenient and (line[:1].isspace() or line.startswith("- ")):
111 continue
112 if line.lstrip().startswith("- "):
113 raise TicketError("frontmatter lists must be inline: `key: [a, b]`")
114 if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
115 continue
116 key, _, value = line.partition(":")
117 value = value.split(" #")[0].strip()
118 quoted = QUOTED_COMMENT_RE.match(value)
119 data[key.strip()] = _scalar(quoted.group(1) if quoted else value)
120 return data
123def _scalar(value: str):
124 if value.startswith("[") and value.endswith("]"):
125 inner = value[1:-1].strip()
126 return [] if not inner else [_scalar(v.strip()) for v in inner.split(",")]
127 if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
128 if value[0] == '"':
129 try:
130 return str(json.loads(value))
131 except ValueError:
132 pass
133 return value[1:-1] if value[0] == '"' else value[1:-1].replace("''", "'")
134 if value in ("true", "false"):
135 return value == "true"
136 if re.fullmatch(r"-?\d+", value):
137 return int(value)
138 return value
141def set_frontmatter_value(text: str, key: str, value: str) -> str:
142 m = FRONTMATTER_RE.match(text)
143 if not m:
144 raise TicketError("ticket has no frontmatter")
145 block = m.group(1)
146 pattern = re.compile(rf"^{re.escape(key)}:.*$\n?", re.M)
147 if value == "":
148 block = pattern.sub("", block).rstrip("\n")
149 elif pattern.search(block):
150 block = pattern.sub(lambda _: f"{key}: {value}\n", block, count=1).rstrip("\n")
151 else:
152 block = f"{block}\n{key}: {value}"
153 return text[: m.start(1)] + block + text[m.end(1) :]
156def _list(value, where: str) -> list:
157 if value in (None, ""):
158 return []
159 if not isinstance(value, list):
160 raise TicketError(f"{where}: after must be a list")
161 return value
164def _flag(value) -> bool:
165 return str(value).lower() == "true"
168def _one_of(value, allowed: tuple, where: str, field: str):
169 """`value` when it is absent (`""`) or one of `allowed`; else the error naming them."""
170 if value not in ("", *allowed):
171 raise TicketError(f"{where}: {field} {value!r} is not one of {', '.join(allowed)}")
172 return value
175# ---------------------------------------------------------------- loading
178def read_text(path: Path) -> str:
179 # utf-8-sig: Windows editors can save a byte-order mark, which would hide the frontmatter.
180 return path.read_text(encoding="utf-8-sig")
183def tickets_dir(folder: Path) -> Path:
184 """Where a folder's `tickets.toml`, epic folders, leaf files, and plans are: `<folder>/tickets/` for an
185 initiative or an epic, the folder itself for a backlog, which has no file of its own."""
186 if not (folder / f"{folder.name}.md").is_file() and not (folder / TICKETS).is_dir():
187 return folder
188 if (folder / BREAKDOWN).is_file() or any(
189 NAME_RE.match(p.name) or (EPIC_RE.match(p.name) and p.is_dir()) for p in folder.iterdir()
190 ):
191 raise TicketError(
192 f"{folder.name} uses the earlier layout, its tickets beside {folder.name}.md instead of in "
193 f"{folder.name}/{TICKETS}/; `bmad migrate` moves it"
194 )
195 return folder / TICKETS
198def shown(folder: Path) -> str:
199 """A folder's tickets as messages name them: `epic-cart/tickets`, or `backlog`."""
200 return f"{folder.name}/{TICKETS}" if tickets_dir(folder) != folder else folder.name
203def container_of(folder: Path) -> Path:
204 """A `tickets/` folder given as `<dir>` stands for the folder that holds it."""
205 return folder.parent if folder.name == TICKETS else folder
208def load_breakdown(folder: Path) -> dict:
209 path = tickets_dir(folder) / BREAKDOWN
210 if not path.is_file():
211 return {}
212 where = f"{shown(folder)}/{BREAKDOWN}"
213 try:
214 data = tomllib.loads(read_text(path))
215 except tomllib.TOMLDecodeError as e:
216 raise TicketError(f"{where}: {e}") from e
217 floor = data.get("next_id", 0)
218 if isinstance(floor, bool) or not isinstance(floor, int):
219 raise TicketError(f"{where}: `next_id` must be a whole number")
220 kinds = [f"[[{t}]]" for t in TABLES if t in data]
221 if len(kinds) > 1:
222 raise TicketError(f"{where}: a breakdown holds one kind of table, not {' and '.join(kinds)}")
223 for table in TABLES:
224 rows = data.get(table, [])
225 if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
226 raise TicketError(f"{where}: write `[[{table}]]` tables, one per {table}")
227 for r in rows:
228 for key in ("covers", "after", "references", "notes"):
229 if not isinstance(r.get(key, []), list):
230 raise TicketError(f"{where}: `{key}` must be a list")
231 r["id"] = _id(r.get("id"))
232 if r["id"] is None:
233 raise TicketError(f"{where}: every {table} needs an `id`: a number, or letters and digits")
234 if table != "entry":
235 if not isinstance(r.get("slug"), str) or not r["slug"]:
236 raise TicketError(f"{where}: {table} {r['id']} needs a `slug`")
237 for a in r.get("after", []):
238 if not isinstance(a, dict) or (_id(a.get(table)) is None and not isinstance(a.get(table), str)):
239 raise TicketError(
240 f'{where}: an {table}\'s `after` takes tables: [{{ {table} = <id or slug>, needs = "..." }}]'
241 )
242 return data
245def _id(value):
246 """An id as the tree compares it, else None: a number, or letters and digits; digits alone are the number."""
247 if isinstance(value, bool):
248 return None
249 if isinstance(value, int):
250 return value
251 # A frontmatter value of true or false is read as a boolean, so neither can be an id.
252 if isinstance(value, str) and ID_RE.match(value) and value not in ("true", "false"):
253 return int(value) if value.isdigit() else value
254 return None
257def load_container(folder: Path) -> dict:
258 path = folder / f"{folder.name}.md"
259 if not path.is_file():
260 raise TicketError(f"{folder.name}: no {path.name}")
261 fm = parse_frontmatter(read_text(path))
262 if fm.get("type") not in CONTAINER_TYPES:
263 raise TicketError(
264 f"{folder.name}/{path.name}: type {fm.get('type')!r} is not one of {', '.join(CONTAINER_TYPES)}"
265 )
266 status = _one_of(fm.get("status", ""), CONTAINER_STATUSES, f"{folder.name}/{path.name}", "status")
267 return {
268 "slug": folder.name,
269 "tracker_id": str(fm.get("tracker_id", "") or ""),
270 "status": status,
271 "refined": str(fm.get("refined", "")).lower() != "false",
272 "raw_after": _list(fm.get("after"), f"{folder.name}.md"),
273 }
276def file_unknown(text: str) -> str:
277 """The `Unknown:` lines of a leaf file's body, as `pull` writes an entry's `unknown` into Notes."""
278 body = COMMENT_RE.sub("", FRONTMATTER_RE.sub("", text, count=1))
279 return "; ".join(m.group(1).strip() for m in UNKNOWN_RE.finditer(body))
282def load_folder(folder: Path, problems: list[str]) -> list[dict]:
283 """One row per ticket in a folder, in build order: every breakdown entry, joined to its
284 leaf file when one exists, then leaf files the breakdown does not list. Plans then set
285 the status fields of the rows they join."""
286 where, at = folder.name, shown(folder)
287 rows = {}
288 for e in load_breakdown(folder).get("entry", []):
289 n, kind = e["id"], e.get("type")
290 if kind not in LEAF_TYPES:
291 raise TicketError(f"{at}/{BREAKDOWN}: entry {n} type {kind!r} is not one of {', '.join(LEAF_TYPES)}")
292 if n in rows:
293 raise TicketError(f"{at}/{BREAKDOWN}: two entries with id {n}")
294 rows[n] = {
295 "epic": where,
296 "id": n,
297 "file": None,
298 "type": kind,
299 "tracker_id": "",
300 "title": str(e.get("title", "")),
301 "status": "",
302 "tracker_status": "",
303 "state": "planned",
304 "assignee": "",
305 "refined": False,
306 "refine": kind == "bug" or _flag(e.get("refine", False)),
307 "description": str(e.get("description", "")),
308 "verify": str(e.get("verify", "")),
309 "unknown": str(e.get("unknown", "")),
310 "references": [str(v) for v in e.get("references", [])],
311 "notes": [str(v) for v in e.get("notes", [])],
312 "risk": str(e.get("risk", "")),
313 "hitl": _flag(e.get("hitl", False)),
314 "plan_checkpoint": _flag(e.get("plan_checkpoint", False)),
315 "done_checkpoint": _flag(e.get("done_checkpoint", False)),
316 "covers": [str(c) for c in e.get("covers", [])],
317 "estimate": e.get("estimate", ""),
318 "blocked_at": "",
319 "blocked_reason": "",
320 "raw_after": _list(e.get("after"), f"{at}/{BREAKDOWN} entry {n}"),
321 "entry_after": None,
322 }
323 unlisted, stray, plans = {}, [], []
324 seen = {}
325 for path in sorted(tickets_dir(folder).glob("*.md")):
326 text = read_text(path)
327 fm = parse_frontmatter(text, lenient=True)
328 if not fm and text.startswith("---") and NAME_RE.match(path.name):
329 raise TicketError(f"{at}/{path.name}: frontmatter does not close")
330 if fm.get("type") not in LEAF_TYPES:
331 if "ticket" in fm:
332 plans.append((path.name, fm))
333 continue
334 try:
335 fm = parse_frontmatter(text)
336 except TicketError as e:
337 raise TicketError(f"{at}/{path.name}: {e}") from e
338 status = _one_of(fm.get("status", ""), STATUSES, f"{at}/{path.name}", "status")
339 tracker_status = _one_of(fm.get("tracker_status", ""), STATES, f"{at}/{path.name}", "tracker_status")
340 n = _id(fm.get("id"))
341 if n is not None:
342 if n in seen:
343 raise TicketError(f"{seen[n]} and {path.name} share the id {n}")
344 seen[n] = path.name
345 row = rows.get(n) if n is not None else None
346 if row is None:
347 row = {"epic": where, "id": n, "raw_after": [], "entry_after": None, "covers": [], "title": ""}
348 row["refine"] = True
349 if n is None:
350 stray.append(row)
351 else:
352 unlisted[n] = row
353 else:
354 row["entry_after"] = row["raw_after"]
355 row["entry_hitl"] = row["hitl"]
356 row.update(
357 {
358 "file": path.name,
359 "type": fm.get("type"),
360 "tracker_id": str(fm.get("tracker_id", "") or ""),
361 "title": str(fm.get("title", "") or row["title"]),
362 "status": status,
363 "tracker_status": tracker_status,
364 "state": tracker_status or STATE_OF[status],
365 "assignee": str(fm.get("assignee", "") or ""),
366 "refined": _flag(fm.get("refined", False)),
367 "refine": row["refine"] or fm.get("type") == "bug",
368 "hitl": _flag(fm.get("hitl", False)),
369 "covers": [str(c) for c in fm["covers"]] if isinstance(fm.get("covers"), list) else row["covers"],
370 "estimate": fm.get("estimate", row.get("estimate", "")),
371 "risk": fm["risk"] if isinstance(fm.get("risk"), str) else row.get("risk", ""),
372 "blocked_at": fm.get("blocked_at", ""),
373 "blocked_reason": str(fm.get("blocked_reason", "") or ""),
374 "unknown": file_unknown(text),
375 }
376 )
377 row["raw_after"] = _list(fm.get("after", []), f"{at}/{path.name}")
378 out = list(rows.values()) + [unlisted[n] for n in sorted(unlisted, key=lambda n: (isinstance(n, str), n))] + stray
379 join_plans(out, plans, at, problems)
380 return out
383def join_plans(rows: list[dict], plans: list[tuple[str, dict]], where: str, problems: list[str]) -> None:
384 """Set each plan's status fields on the one row its `ticket` names; a bad plan is a problem, not an error."""
385 for name, fm in plans:
386 ticket = fm["ticket"]
387 row = None
388 if _id(ticket) is not None:
389 ticket = _id(ticket)
390 row = next((r for r in rows if r["id"] == ticket), None)
391 if row is None and isinstance(ticket, str) and ticket:
392 row = next((r for r in rows if r["file"] == f"{ticket}.md"), None)
393 if row is None:
394 problems.append(f"{where}/{name}: ticket {ticket!r} names no entry or leaf file in {where}; skipped")
395 continue
396 if "plan" in row:
397 raise TicketError(f"{where}/{row['plan']} and {name} are both plans for ticket {ticket!r}")
398 fields = {k: str(fm.get(k, "") or "") for k in PLAN_FIELDS}
399 if "assignee" not in fm:
400 # A tracker's assignee is mirrored into the leaf file; the builds' plans carry no assignee.
401 fields["assignee"] = row.get("assignee", "")
402 try:
403 _one_of(fields["status"], STATUSES, f"{where}/{name}", "status")
404 except TicketError as e:
405 problems.append(f"{e}; the ticket reads as blocked until the plan is fixed")
406 fields.update(status="blocked", blocked_reason=f"{name} has an unknown status {fields['status']!r}")
407 row.update({"plan": name, "state": row["tracker_status"] or STATE_OF[fields["status"]], **fields})
410def epic_folders(initiative: Path) -> list[Path]:
411 return sorted(
412 d
413 for d in tickets_dir(initiative).glob("epic-*")
414 if d.is_dir() and ((d / f"{d.name}.md").is_file() or (tickets_dir(d) / BREAKDOWN).is_file())
415 )
418def load_tree(folder: Path) -> dict:
419 """The folder asked about plus every epic its tickets can name."""
420 children = load_breakdown(folder).get("initiative")
421 if children is not None:
422 names = ", ".join(c["slug"] for c in children)
423 raise TicketError(f"{folder.name}: a parent initiative holds initiatives; name one of them: {names}")
424 epics = epic_folders(folder)
425 holder = folder.parent.parent if folder.parent.name == TICKETS else None
426 if epics or "epic" in load_breakdown(folder):
427 scope, initiative, folders = None, folder, None
428 elif holder is not None and folder in epic_folders(holder):
429 scope, initiative, folders = folder.name, holder, epic_folders(holder)
430 epics = folders
431 else:
432 scope, initiative, folders = folder.name, None, [folder]
433 listed = load_breakdown(initiative).get("epic", []) if initiative else []
434 order = [e.get("slug") for e in listed]
435 epics.sort(key=lambda d: (order.index(d.name) if d.name in order else len(order), d.name))
436 if folders is None:
437 folders = [*epics, folder]
438 epic_ids = {}
439 for e in listed:
440 if e["id"] in epic_ids.values():
441 raise TicketError(f"{shown(initiative)}/{BREAKDOWN}: two epics with id {e['id']}")
442 if e["slug"] in epic_ids:
443 raise TicketError(f"{shown(initiative)}/{BREAKDOWN}: two epics with slug {e['slug']}")
444 epic_ids[e["slug"]] = e["id"]
445 problems = []
446 tickets = [t for f in folders for t in load_folder(f, problems)]
447 for t in tickets:
448 t["key"] = f"{t['epic']}/{t['id']}" if t["id"] is not None else f"{t['epic']}/{t['file']}"
449 tree = {
450 "scope": scope,
451 "initiative": initiative,
452 "folders": {f.name: f for f in folders},
453 "epic_ids": epic_ids,
454 "containers": {f.name: load_container(f) for f in epics},
455 "tickets": tickets,
456 "problems": problems,
457 }
458 _resolve(tree)
459 _check_cycles(tickets, tree["containers"])
460 return tree
463def _resolve(tree: dict) -> None:
464 tickets, containers = tree["tickets"], tree["containers"]
465 by_key = {t["key"]: t for t in tickets}
466 slugs = {i: slug for slug, i in tree["epic_ids"].items()}
467 ids = {c["tracker_id"]: slug for slug, c in containers.items() if c["tracker_id"]}
468 ids.update({t["tracker_id"]: t["key"] for t in tickets if t["tracker_id"]})
469 owners: dict[str, list[str]] = {}
470 carriers = [(s, c["tracker_id"]) for s, c in containers.items()] + [(t["key"], t["tracker_id"]) for t in tickets]
471 for key, tid in carriers:
472 if tid:
473 owners.setdefault(tid.lower(), []).append(key)
474 for tid, keys in owners.items():
475 if len(keys) > 1:
476 raise TicketError(f"tracker_id {tid!r} is on more than one ticket: {', '.join(keys)}")
478 def sibling(t, ref, where):
479 """A bare number is always a sibling's id and a quoted one never is; an id with a letter is one when a
480 sibling has it."""
481 mates = [o for o in tickets if o["epic"] == t["epic"]]
482 if isinstance(ref, int) and not isinstance(ref, bool):
483 hit = next((o for o in mates if o["id"] == ref), None)
484 if hit is None:
485 raise TicketError(f"{where}: after {ref!r} names no entry in {t['epic']}")
486 return hit["key"]
487 for o in mates:
488 if isinstance(o["id"], str) and o["id"] == ref:
489 return o["key"]
490 for o in mates:
491 if o["file"] and ref in (o["file"], o["file"][:-3]):
492 return o["key"]
493 return None
495 def resolve(t, refs, where):
496 keys = []
497 for ref in refs:
498 text = str(ref)
499 key = sibling(t, ref, where) if "id" in t else None
500 m = CROSS_RE.match(text)
501 slug = slugs.get(_id(m.group(1))) if m else None
502 if key is None and slug:
503 key = f"{slug}/{_id(m.group(2))}"
504 if key not in by_key:
505 raise TicketError(f"{where}: after {ref!r} names no entry in {slug}")
506 if key is None and EPIC_RE.match(text):
507 if text in tree["epic_ids"] and text not in containers:
508 raise TicketError(
509 f"{where}: after {ref!r} names an epic {BREAKDOWN} lists that has no folder yet; "
510 f"create {TICKETS}/{text}/{text}.md first"
511 )
512 if text not in containers:
513 raise TicketError(f"{where}: after {ref!r} names no epic in this initiative")
514 key = text
515 if key is None:
516 key = ids.get(text)
517 if key is None and m:
518 raise TicketError(f"{where}: after {ref!r} names no epic id in this initiative's {BREAKDOWN}")
519 if key is None and NAME_RE.match(text if text.endswith(".md") else f"{text}.md"):
520 raise TicketError(
521 f"{where}: after {ref!r} matches no ticket in {t['epic']}; a file name names a pulled ticket in the "
522 "same folder only: use the entry's id, or move a backlog ticket into the epic as an entry"
523 )
524 if key is None:
525 raise TicketError(f"{where}: after {ref!r} matches no ticket")
526 if key not in keys:
527 keys.append(key)
528 return keys
530 at = {name: shown(f) for name, f in tree["folders"].items()}
531 for t in tickets:
532 where = f"{at[t['epic']]}/{t['file']}" if t["file"] else f"{at[t['epic']]}/{BREAKDOWN} entry {t['id']}"
533 t["after"] = resolve(t, t.pop("raw_after"), where)
534 planned = t.pop("entry_after")
535 t["gated_by"] = []
536 entry_hitl = t.pop("entry_hitl", None)
537 t["drift"] = {}
538 if planned is not None:
539 entry_after = resolve(t, planned, where)
540 if sorted(entry_after) != sorted(t["after"]):
541 t["drift"]["after"] = {"file": t["after"], "entry": entry_after}
542 if entry_hitl != t["hitl"]:
543 t["drift"]["hitl"] = {"file": t["hitl"], "entry": entry_hitl}
544 for slug, c in containers.items():
545 gates = resolve({"epic": slug}, c.pop("raw_after"), f"{slug}.md")
546 c["after"] = gates
547 for t in tickets:
548 if t["epic"] == slug:
549 t["gated_by"] = gates
552def _check_cycles(tickets: list[dict], containers: dict) -> None:
553 sys.setrecursionlimit(max(1000, 3 * len(tickets) + 100))
554 graph = {t["key"]: t["after"] + t["gated_by"] for t in tickets}
555 members = {}
556 for t in tickets:
557 members.setdefault(t["epic"], []).append(t["key"])
558 for slug, c in containers.items():
559 members.setdefault(slug, []).extend(c["after"])
560 state = {}
562 def visit(node, path):
563 if state.get(node) == "done":
564 return
565 if state.get(node) == "active":
566 raise TicketError("cycle through " + " -> ".join(path + [node]))
567 state[node] = "active"
568 for b in graph.get(node, members.get(node, [])):
569 visit(b, path + [node])
570 state[node] = "done"
572 for node in graph:
573 visit(node, [])
576# ---------------------------------------------------------------- views
579def done_keys(tree: dict) -> set:
580 done = {t["key"] for t in tree["tickets"] if t["state"] == "done"}
581 return done | {slug for slug, c in tree["containers"].items() if c["status"] == "done"}
584def in_scope(tree: dict) -> list[dict]:
585 return [t for t in tree["tickets"] if tree["scope"] in (None, t["epic"])]
588def classify(tree: dict) -> dict:
589 done = done_keys(tree)
590 # A ticket in review meets an `after`; an epic gate still waits for the epic to be done.
591 met = done | {t["key"] for t in tree["tickets"] if t["state"] == "review"}
592 groups = {"ready_to_refine": [], "ready_to_start": [], "in_progress": [], "blocked": []}
593 for t in in_scope(tree):
594 s = t["state"]
595 if s in ("done", "dropped"):
596 continue
597 unmet = [b for b in t["after"] if b not in met] + [b for b in t["gated_by"] if b not in done]
598 if t["status"] == "blocked" or t["blocked_at"]:
599 t["waiting_on"] = unmet
600 groups["blocked"].append(t)
601 elif s in ("in-progress", "review"):
602 groups["in_progress"].append(t)
603 elif unmet:
604 t["waiting_on"] = unmet
605 groups["blocked"].append(t)
606 elif t["refine"] and not t["refined"]:
607 groups["ready_to_refine"].append(t) # refining is where its unknown gets settled
608 elif t.get("unknown"):
609 groups["blocked"].append(t)
610 else:
611 groups["ready_to_start"].append(t)
612 return groups
615def longest_remaining_chain(tree: dict) -> list[str]:
616 remaining = {t["key"]: t for t in tree["tickets"] if t["state"] not in ("done", "dropped")}
617 memo = {}
619 def chain(k):
620 if k in memo:
621 return memo[k]
622 best = []
623 for b in remaining[k]["after"] + remaining[k]["gated_by"]:
624 if b in remaining:
625 c = chain(b)
626 if len(c) > len(best):
627 best = c
628 memo[k] = best + [k]
629 return memo[k]
631 longest = []
632 for t in in_scope(tree):
633 if t["key"] in remaining:
634 c = chain(t["key"])
635 if len(c) > len(longest):
636 longest = c
637 return [ref(k, None, tree) for k in longest]
640def ref(key: str, epic: str | None, tree: dict) -> str | int:
641 """A key as the plan writes it: a sibling's id, `<epic id>.<id>` elsewhere, an epic's slug."""
642 slug, _, n = key.partition("/")
643 if not n:
644 return slug
645 if _id(n) is None:
646 return key
647 if slug == epic:
648 return _id(n)
649 if slug in tree["epic_ids"]:
650 return f"{tree['epic_ids'][slug]}.{n}"
651 return key
654def declared_after(tree: dict) -> dict:
655 """Each epic the initiative's `tickets.toml` lists, in build order, with its `after` as `[{epic, needs}]`."""
656 if not tree["initiative"]:
657 return {}
658 listed = load_breakdown(tree["initiative"]).get("epic", [])
659 slugs = [e["slug"] for e in listed]
660 by_id = {i: slug for slug, i in tree["epic_ids"].items()}
661 out = {}
662 for e in listed:
663 out[e["slug"]] = []
664 for a in e.get("after", []):
665 needed = by_id.get(_id(a.get("epic")), a.get("epic"))
666 if needed not in slugs:
667 raise TicketError(
668 f"{shown(tree['initiative'])}/{BREAKDOWN}: {e['slug']} is after {a.get('epic')!r}, which is no epic listed"
669 )
670 out[e["slug"]].append({"epic": needed, "needs": a.get("needs", "")})
671 return out
674def unpinned_after(tree: dict, declared: dict) -> list[dict]:
675 """Declared `after` lines whose waiting epic has tickets but none waiting on the named epic."""
676 out = []
677 for slug, edges in declared.items():
678 mine = [t for t in tree["tickets"] if t["epic"] == slug]
679 for a in edges:
680 needed = a["epic"]
681 pinned = any(b == needed or b.startswith(f"{needed}/") for t in mine for b in t["after"] + t["gated_by"])
682 if mine and not pinned and tree["scope"] in (None, slug):
683 out.append({"epic": slug, "after": needed, "needs": a["needs"]})
684 return out
687def cross_epic_after(tree: dict, declared: dict) -> dict:
688 """`undeclared_after`: an entry's `after` into an epic its epic does not declare. `order_conflict`: an epic
689 that waits, declared or through its tickets, on an epic later in the initiative's build order."""
690 order = list(declared)
691 undeclared, conflicts = [], []
693 def conflict(epic, needed):
694 pair = {"epic": epic, "after": needed}
695 if order.index(needed) > order.index(epic) and pair not in conflicts and tree["scope"] in (None, epic):
696 conflicts.append(pair)
698 for slug, edges in declared.items():
699 for a in edges:
700 conflict(slug, a["epic"])
701 for slug, c in tree["containers"].items():
702 for b in c["after"]:
703 if slug in declared and b.partition("/")[0] in declared:
704 conflict(slug, b.partition("/")[0])
705 for t in tree["tickets"]:
706 if t["epic"] not in declared:
707 continue
708 allowed = {a["epic"] for a in declared[t["epic"]]}
709 for b in t["after"]:
710 needed = b.partition("/")[0]
711 if needed == t["epic"] or needed not in declared:
712 continue
713 conflict(t["epic"], needed)
714 if needed not in allowed and tree["scope"] in (None, t["epic"]):
715 undeclared.append(
716 {"epic": t["epic"], "after": needed, "ref": row_ref(t, tree), "names": ref(b, t["epic"], tree)}
717 )
718 return {"undeclared_after": undeclared, "order_conflict": conflicts}
721def row_ref(t: dict, tree: dict) -> str | None:
722 """What `find` resolves to this ticket in the folder the command ran on; never the title,
723 which can repeat across epics."""
724 if t["id"] is not None and t["epic"] in tree["epic_ids"]:
725 return f"{tree['epic_ids'][t['epic']]}.{t['id']}"
726 if t["id"] is not None and t["epic"] == tree["scope"]:
727 return str(t["id"])
728 return t["file"]
731def public(t: dict, tree: dict, blocks: dict | None = None) -> dict:
732 row = {
733 k: t[k]
734 for k in (
735 "epic",
736 "id",
737 "file",
738 "type",
739 "tracker_id",
740 "title",
741 "status",
742 "tracker_status",
743 "state",
744 "assignee",
745 "hitl",
746 "risk",
747 "covers",
748 "estimate",
749 "refine",
750 "refined",
751 "blocked_at",
752 "blocked_reason",
753 )
754 }
755 row["ref"] = row_ref(t, tree)
756 row["after"] = [ref(b, t["epic"], tree) for b in t["after"]]
757 if t["gated_by"]:
758 row["gated_by"] = t["gated_by"]
759 if blocks is not None:
760 row["blocks"] = [ref(b, t["epic"], tree) for b in blocks.get(t["key"], [])]
761 if t["drift"]:
762 row["drift"] = dict(t["drift"])
763 if "after" in row["drift"]:
764 row["drift"]["after"] = {k: [ref(b, t["epic"], tree) for b in v] for k, v in t["drift"]["after"].items()}
765 if t.get("waiting_on"):
766 row["waiting_on"] = [ref(b, t["epic"], tree) for b in t["waiting_on"]]
767 for key in ("unknown", "plan_checkpoint", "done_checkpoint"):
768 if t.get(key):
769 row[key] = t[key]
770 return row
773# ---------------------------------------------------------------- store
776def find_project_root(start: Path) -> Path | None:
777 for p in [start, *start.parents]:
778 if (p / "_bmad").is_dir():
779 return p
780 return None
783def project_root_for(args, start: Path) -> Path | None:
784 return Path(args.project_root).resolve() if args.project_root else find_project_root(start)
787def store_config(project_root: Path | None) -> dict:
788 """The `[tickets]` table of the project's store config, empty when there is none."""
789 if not project_root:
790 return {}
791 cfg = project_root / "_bmad" / "custom" / "ticketing-store-config.toml"
792 if not cfg.is_file():
793 return {}
794 tickets = tomllib.loads(read_text(cfg)).get("tickets", {})
795 return tickets if isinstance(tickets, dict) else {}
798def store_name(project_root: Path | None) -> str:
799 store = store_config(project_root).get("store", "repo")
800 return store if isinstance(store, str) and store else "repo"
803def central_config(project_root: Path) -> dict:
804 """The BMad config with its layers merged by the project's own `config_utils.py`."""
805 path = project_root / "_bmad" / "scripts" / "config_utils.py"
806 if not path.is_file():
807 raise TicketError(f"cannot read the BMad config: {path} is missing")
808 spec = importlib.util.spec_from_file_location("bmad_config_utils", path)
809 module = importlib.util.module_from_spec(spec)
810 spec.loader.exec_module(module)
811 try:
812 return module.load_central_config(project_root)
813 except module.ConfigError as e:
814 raise TicketError(str(e)) from e
817def tickets_root(project_root: Path, config: dict | None = None) -> Path:
818 """`{output_folder}` for the project: the ticket tree lives beside the documents."""
819 config = central_config(project_root) if config is None else config
820 core = config.get("core", {})
821 output = str(core.get("output_folder", "") if isinstance(core, dict) else "")
822 output = output.replace("{project-root}", str(project_root))
823 return project_root / output
826def active_initiative(project_root: Path) -> Path:
827 """`{output_folder}/{active_initiative}` for the project."""
828 config = central_config(project_root)
829 core = config.get("core", {})
830 name = core.get("active_initiative") if isinstance(core, dict) else None
831 if not isinstance(name, str) or not name.strip():
832 raise TicketError(
833 "no active initiative: set core.active_initiative in _bmad/custom/config.user.toml, or pass a folder"
834 )
835 folder = (tickets_root(project_root, config) / name.strip()).resolve()
836 if not folder.is_dir():
837 raise TicketError(f"active initiative folder not found: {folder}")
838 return folder
841# ---------------------------------------------------------------- commands
844def _folder(args) -> Path:
845 if args.dir is None:
846 root = project_root_for(args, Path.cwd())
847 if root is None:
848 raise TicketError("no project root found: no _bmad/ at or above the working directory; pass --project-root")
849 # The store is then read from this project even when output_folder lies outside it.
850 args.project_root = str(root)
851 folder = active_initiative(root)
852 backlog = (tickets_root(root) / "backlog").resolve()
853 args.backlog = backlog if backlog.is_dir() and backlog != folder else None
854 return folder
855 return locate_dir(args, args.dir)
858def locate_dir(args, name: str) -> Path:
859 """A `<dir>` argument as a folder: as given, else under the store, the active initiative and its `tickets/`
860 folder, or the project root."""
861 folder = Path(name).resolve()
862 root = None if folder.is_dir() or Path(name).is_absolute() else project_root_for(args, Path.cwd())
863 bases = []
864 if root is not None:
865 try:
866 bases = [tickets_root(root), root]
867 except TicketError: # no BMad config to name the store: the project root alone
868 bases = [root]
869 else:
870 try:
871 active = active_initiative(root)
872 except TicketError: # none set: the store and the project root
873 pass
874 else:
875 bases[1:1] = [active, *([active / TICKETS] if (active / TICKETS).is_dir() else [])]
876 for base in bases:
877 if (base / name).is_dir():
878 return container_of((base / name).resolve())
879 if not folder.is_dir():
880 looked = [] if Path(name).is_absolute() else [Path.cwd(), *bases]
881 where = f" (looked under {', '.join(str(p) for p in looked)})" if looked else ""
882 raise TicketError(f"not a folder: {name}{where}")
883 return container_of(folder)
886def with_backlog(args, out: dict, view) -> dict:
887 """Add the backlog's view to a command that ran on the active initiative."""
888 backlog = getattr(args, "backlog", None)
889 if backlog is not None:
890 try:
891 out["backlog"] = view(backlog)
892 except (TicketError, OSError, ValueError) as e:
893 out["backlog"] = {"folder": backlog.name, "error": str(e)}
894 for group in ("ready_to_refine", "ready_to_start", "in_progress", "blocked", "tickets"):
895 for row in out["backlog"].get(group, []):
896 # An id here could also name a ticket in the initiative; the file name cannot.
897 row["ref"] = row["file"] or row["ref"]
898 return out
901def locate(args, folder: Path, text: str) -> tuple[dict, dict]:
902 """The ticket a reference names and the tree holding it: the folder's, else, with no `<dir>`, the backlog's."""
903 tree = load_tree(folder)
904 try:
905 return resolve_ticket(tree, text), tree
906 except NoMatch as miss:
907 backlog = getattr(args, "backlog", None)
908 if backlog is None:
909 raise
910 number = text.strip()
911 try:
912 other = load_tree(backlog)
913 other["fallback"] = True
914 try:
915 # A number that is a ticket's tracker id names that ticket, whatever local ids the backlog holds.
916 return by_tracker_id(other, number) if number.isdigit() else resolve_ticket(other, text), other
917 except NoMatch:
918 found = resolve_ticket(other, text)
919 except (NoMatch, OSError, ValueError): # not there, or a backlog that cannot be read
920 raise miss from None
921 if number.isdigit():
922 # A bare number names an entry of some epic; the backlog's file that carries that id is only a guess.
923 named = [ref_name(t, tree) for t in tree["tickets"] if t["id"] == _id(number)]
924 if named:
925 what = f"could name more than one ticket: {', '.join([*named, ref_name(found, other)])}"
926 else:
927 what = f"names no ticket in the initiative, and in the backlog it matches {ref_name(found, other)}"
928 raise TicketError(
929 f"{number!r} is a bare number and {what}; name the ticket's epic folder or its file name"
930 ) from None
931 return found, other
934def next_view(folder: Path) -> dict:
935 tree = load_tree(folder)
936 declared = declared_after(tree)
937 return {
938 "folder": folder.name,
939 **{k: [public(t, tree) for t in v] for k, v in classify(tree).items()},
940 "unpinned_after": unpinned_after(tree, declared),
941 **cross_epic_after(tree, declared),
942 **({"problems": tree["problems"]} if tree["problems"] else {}),
943 }
946def is_parent(folder: Path) -> bool:
947 return "initiative" in load_breakdown(folder)
950def parent_view(folder: Path) -> dict:
951 """A parent initiative's children in build order: each with its declared `after` and, once its folder exists,
952 its file's status and its ticket counts; `waiting_on` names the prerequisites not yet done."""
953 listed = load_breakdown(folder)["initiative"]
954 where = f"{shown(folder)}/{BREAKDOWN}"
955 ids = {}
956 for c in listed:
957 if c["id"] in ids.values():
958 raise TicketError(f"{where}: two initiatives with id {c['id']}")
959 if c["slug"] in ids:
960 raise TicketError(f"{where}: two initiatives with slug {c['slug']}")
961 ids[c["slug"]] = c["id"]
962 by_id = {i: slug for slug, i in ids.items()}
963 rows = []
964 for c in listed:
965 after = []
966 for a in c.get("after", []):
967 needed = by_id.get(_id(a.get("initiative")), a.get("initiative"))
968 if needed not in ids:
969 raise TicketError(
970 f"{where}: {c['slug']} is after {a.get('initiative')!r}, which is no initiative listed"
971 )
972 after.append({"initiative": needed, "needs": str(a.get("needs", ""))})
973 row = {"id": c["id"], "slug": c["slug"], "title": str(c.get("title", "")), "after": after, "folder": None}
974 child = folder.parent / c["slug"]
975 if child.is_dir():
976 row.update(
977 folder=child.name, status=load_container(child)["status"], counts=counts(load_tree(child)["tickets"])
978 )
979 rows.append(row)
980 graph = {r["slug"]: [a["initiative"] for a in r["after"]] for r in rows}
981 state = {}
983 def visit(node, path):
984 if state.get(node) == "active":
985 raise TicketError("cycle through " + " -> ".join(path + [node]))
986 if state.get(node) is None:
987 state[node] = "active"
988 for b in graph[node]:
989 visit(b, path + [node])
990 state[node] = "done"
992 for node in graph:
993 visit(node, [])
994 order = list(graph)
995 done = {r["slug"] for r in rows if r.get("status") == "done"}
996 conflicts = []
997 for r in rows:
998 r["waiting_on"] = [b for b in graph[r["slug"]] if b not in done]
999 conflicts += [
1000 {"initiative": r["slug"], "after": b} for b in graph[r["slug"]] if order.index(b) > order.index(r["slug"])
1001 ]
1002 return {"folder": folder.name, "initiatives": rows, "order_conflict": conflicts}
1005def cmd_next(args) -> dict:
1006 folder = _folder(args)
1007 store = store_name(project_root_for(args, folder))
1008 if store != "repo" and not args.synced:
1009 raise StoreRefusal(f"store is {store}: sync ticket status from the tracker first, then rerun with --synced")
1010 if is_parent(folder):
1011 out = parent_view(folder)
1012 out["ready"] = [
1013 r["slug"] for r in out["initiatives"] if not r["waiting_on"] and r.get("status") not in ("done", "dropped")
1014 ]
1015 return with_backlog(args, {"store": store, **out}, next_view)
1016 return with_backlog(args, {"store": store, **next_view(folder)}, next_view)
1019def next_id(tree: dict, folder: str) -> int:
1020 """The id a new ticket in the folder takes: one past the highest number its entries and leaf files use, or
1021 `next_id` at the top of its `tickets.toml` when that is higher."""
1022 # `6a` uses up 6: a lettered id is a split of that number.
1023 used = [re.match(r"\d+", str(t["id"])) for t in tree["tickets"] if t["epic"] == folder]
1024 # An entry moved to another folder took its id along; the counter it left keeps that id from coming back.
1025 floor = load_breakdown(tree["folders"][folder]).get("next_id", 0)
1026 return max(max((int(m.group()) for m in used if m), default=0) + 1, floor)
1029def counts(tickets: list[dict]) -> dict:
1030 out = {"total": len(tickets)}
1031 for t in tickets:
1032 out[t["state"]] = out.get(t["state"], 0) + 1
1033 return out
1036def status_view(folder: Path) -> dict:
1037 tree = load_tree(folder)
1038 declared = declared_after(tree)
1039 tickets = in_scope(tree)
1040 blocks = {}
1041 for t in tree["tickets"]:
1042 for b in t["after"]:
1043 blocks.setdefault(b, []).append(t["key"])
1044 out = {
1045 "folder": folder.name,
1046 "tickets": [public(t, tree, blocks) for t in tickets],
1047 "counts": counts(tickets),
1048 "longest_remaining_chain": longest_remaining_chain(tree),
1049 "unpinned_after": unpinned_after(tree, declared),
1050 **cross_epic_after(tree, declared),
1051 **({"problems": tree["problems"]} if tree["problems"] else {}),
1052 }
1053 if tree["scope"] is None:
1054 # The declared epics in build order, folder or not, then any folder the breakdown does not list.
1055 out["epics"] = []
1056 for slug in [*declared, *sorted(s for s in tree["containers"] if s not in declared)]:
1057 row = {"slug": slug, "id": tree["epic_ids"].get(slug), "after": declared.get(slug, [])}
1058 c = tree["containers"].get(slug)
1059 if c is None:
1060 row["folder"] = None
1061 else:
1062 row.update(
1063 folder=slug,
1064 status=c["status"],
1065 refined=c["refined"],
1066 gated_by=c["after"],
1067 blocks=[ref(b, None, tree) for b in blocks.get(slug, [])],
1068 next_id=next_id(tree, slug),
1069 )
1070 out["epics"].append(row)
1071 else:
1072 out["next_id"] = next_id(tree, tree["scope"])
1073 return out
1076def cmd_status(args) -> dict:
1077 folder = _folder(args)
1078 store = store_name(project_root_for(args, folder))
1079 view = parent_view if is_parent(folder) else status_view
1080 return with_backlog(args, {"store": store, **view(folder)}, status_view)
1083PULLED = """---
1084{frontmatter}
1085---
1087# {heading}
1089## Description
1091{description}
1093## Requirements
1095Verify: {verify}
1097## References
1099- parent — {parent}
1100{references}{notes}"""
1103def title_slug(title: str) -> str:
1104 title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
1105 return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:60].rstrip("-") or "untitled"
1108def resolve_ticket(tree: dict, text: str) -> dict:
1109 """The one row a reference names; see `<ref>` above."""
1110 tickets = tree["tickets"]
1111 ref = text.strip()
1112 if not ref:
1113 raise TicketError("the ticket reference is empty")
1114 low = ref.lower()
1115 hits = []
1116 m = CROSS_RE.match(ref)
1117 if m:
1118 slug = {i: s for s, i in tree["epic_ids"].items()}.get(_id(m.group(1)))
1119 hits = [t for t in tickets if slug and t["epic"] == slug and t["id"] == _id(m.group(2))]
1120 elif _id(ref) is not None and tree["scope"]:
1121 hits = [t for t in tickets if t["epic"] == tree["scope"] and t["id"] == _id(ref)]
1122 if not hits and isinstance(_id(ref), str):
1123 # An id with a letter names its ticket in any epic before the words of a title can match it.
1124 hits = [t for t in tickets if t["id"] == _id(ref)]
1125 for pool in (in_scope(tree), tickets):
1126 if not hits:
1127 hits = [t for t in pool if t["file"] and low in (t["file"].lower(), t["file"][:-3].lower())]
1128 if not hits:
1129 hits = [t for t in tickets if t["tracker_id"] and low == t["tracker_id"].lower()]
1130 if not hits and not ref.isdigit():
1131 hits = [t for t in in_scope(tree) if low in t["title"].lower()]
1132 if not hits:
1133 raise NoMatch(f"no ticket matches {ref!r}")
1134 if len(hits) > 1:
1135 names = ", ".join(ref_name(t, tree) for t in hits)
1136 raise TicketError(f"{ref!r} matches more than one ticket: {names}")
1137 return hits[0]
1140def leaf_stem(t: dict, tree: dict) -> str:
1141 """The leaf file's stem, or the one `pull` gives it: `<type>-<slug of the title>`, with `-<id>` added when a
1142 file or an earlier entry in the folder already has that name."""
1143 if t["file"]:
1144 return t["file"][:-3]
1145 stem = f"{t['type']}-{title_slug(t['title'])}"
1146 taken = set()
1147 for o in tree["tickets"]:
1148 if o is t:
1149 break
1150 if o["epic"] == t["epic"] and not o["file"]:
1151 taken.add(f"{o['type']}-{title_slug(o['title'])}")
1152 taken |= {o["file"][:-3] for o in tree["tickets"] if o["epic"] == t["epic"] and o["file"]}
1153 return f"{stem}-{t['id']}" if stem in taken else stem
1156def plan_path(t: dict, tree: dict) -> Path:
1157 """The joined plan, else `<leaf stem>-plan.md`."""
1158 folder = tickets_dir(tree["folders"][t["epic"]])
1159 if t.get("plan"):
1160 return folder / t["plan"]
1161 return folder / f"{leaf_stem(t, tree)}-plan.md"
1164def cmd_find(args) -> dict:
1165 folder = _folder(args)
1166 t, tree = locate(args, folder, args.ref)
1167 home = tree["folders"][t["epic"]]
1168 row = public(t, tree)
1169 if tree.get("fallback") and t["file"]:
1170 row["ref"] = t["file"] # as the backlog view names it: an id could name a ticket in the initiative
1171 # Once a leaf file exists it holds the text and the entry's copy is no longer kept up. Unlisted and stray
1172 # leaves have no entry.
1173 entry = {} if t["file"] else t
1174 container = home / f"{home.name}.md" # an epic's file, or the initiative's for a leaf directly under one
1175 return {
1176 **row,
1177 "folder": home.name,
1178 "description": entry.get("description", ""),
1179 "verify": entry.get("verify", ""),
1180 "references": entry.get("references", []),
1181 "notes": entry.get("notes", []),
1182 "unknown": t.get("unknown", ""),
1183 "epic_file": str(container) if container.is_file() else None,
1184 "story_file": str(tickets_dir(home) / t["file"]) if t["file"] else None,
1185 "plan": str(plan_path(t, tree)),
1186 }
1189def ref_name(t: dict, tree: dict) -> str:
1190 return f"{t['file'] or t['id']} in {t['epic']}"
1193def cmd_pull(args) -> dict:
1194 folder = _folder(args)
1195 tree = load_tree(folder)
1196 n = _id(args.id)
1197 t = next((t for t in in_scope(tree) if t["epic"] == folder.name and t["id"] == n), None)
1198 if n is None or t is None:
1199 raise TicketError(f"{shown(folder)}/{BREAKDOWN} has no entry {args.id}")
1200 if t["file"]:
1201 raise TicketError(f"entry {args.id} is already pulled: {t['file']}")
1202 path = write_leaf(t, tree, project_root_for(args, folder))
1203 return {"file": path.name, "refine": t["refine"]}
1206def parent_reference(folder: Path, root: Path | None) -> str:
1207 """The epic file as a leaf's `- parent —` reference names it."""
1208 epic_file = folder / f"{folder.name}.md"
1209 try:
1210 return Path(os.path.relpath(epic_file, root)).as_posix() if root else epic_file.as_posix()
1211 except ValueError: # another drive on Windows
1212 return epic_file.as_posix()
1215def write_leaf(t: dict, tree: dict, root: Path | None) -> Path:
1216 """Write an entry's leaf file from the entry and name it on the row."""
1217 folder = tree["folders"][t["epic"]]
1218 path = tickets_dir(folder) / f"{leaf_stem(t, tree)}.md"
1219 if path.exists():
1220 raise TicketError(f"{path.name} exists already; change entry {t['id']}'s title")
1221 after = [str(ref(b, t["epic"], tree)) for b in t["after"]]
1222 parent = parent_reference(folder, root)
1223 notes = ([f"Unknown: {t['unknown']}"] if t["unknown"] else []) + t["notes"]
1224 # Other empty fields are left out; `after` and `hitl` stay because `status` compares them with the entry.
1225 # No status: the build writes it when it starts.
1226 fields = [
1227 ("id", str(t["id"])),
1228 ("type", t["type"]),
1229 ("title", json.dumps(t["title"], ensure_ascii=False)),
1230 ("parent", t["epic"]),
1231 ("covers", f"[{', '.join(t['covers'])}]" if t["covers"] else ""),
1232 ("after", f"[{', '.join(after)}]"),
1233 ("refined", "false" if t["refine"] else ""),
1234 ("hitl", "true" if t["hitl"] else "false"),
1235 ("risk", t["risk"]),
1236 ("estimate", json.dumps(str(t["estimate"])) if t["estimate"] != "" else ""),
1237 ]
1238 path.write_text(
1239 PULLED.format(
1240 frontmatter="\n".join(f"{k}: {v}" for k, v in fields if v != ""),
1241 heading=t["title"],
1242 parent=parent,
1243 description=t["description"],
1244 verify=t["verify"],
1245 references="".join(f"- {r}\n" for r in t["references"]),
1246 notes="\n## Notes\n\n" + "".join(f"- {n}\n" for n in notes) if notes else "",
1247 ),
1248 encoding="utf-8",
1249 )
1250 t["file"] = path.name
1251 return path
1254def quoted(value: str) -> str:
1255 """A double-quoted scalar that `parse_frontmatter` reads back exactly."""
1256 # parse_frontmatter cuts a value at " #" and splits lines on these characters, so they go in as escapes.
1257 text = json.dumps(value, ensure_ascii=False).replace(" #", " \\u0023")
1258 return text.translate({c: f"\\u{c:04x}" for c in (0x85, 0x2028, 0x2029)})
1261def edit_frontmatter(path: Path, values: dict[str, str]) -> str:
1262 """Set frontmatter values in a file, keeping its line endings and byte-order mark; an empty value removes
1263 the line. Returns the new text."""
1264 raw = path.read_bytes()
1265 text = raw.decode("utf-8-sig").replace("\r\n", "\n")
1266 for key, value in values.items():
1267 text = set_frontmatter_value(text, key, value)
1268 data = text.encode("utf-8") # before the file is opened, so a failure leaves the file as it was
1269 if b"\r\n" in raw:
1270 data = data.replace(b"\n", b"\r\n")
1271 if raw.startswith(codecs.BOM_UTF8):
1272 data = codecs.BOM_UTF8 + data
1273 path.write_bytes(data)
1274 return text
1277def cmd_mark(args) -> dict:
1278 """Write status, assignee, and the blocked fields to the ticket's plan; never to its leaf file."""
1279 folder = _folder(args)
1280 store = store_name(project_root_for(args, folder))
1281 if store != "repo":
1282 raise StoreRefusal(f"store is {store}: change status through the store's write verb, not this script")
1283 t, tree = locate(args, folder, args.ref)
1284 path = plan_path(t, tree)
1285 blocked = {"blocked_at": "", "blocked_reason": ""}
1286 if args.blocked is not None:
1287 blocked = {"blocked_at": quoted(date.today().isoformat()), "blocked_reason": quoted(args.blocked)}
1288 created = not t.get("plan")
1289 if created:
1290 assignee = args.assignee if args.assignee is not None else t["assignee"]
1291 fields = [
1292 ("title", quoted(t["title"])),
1293 ("ticket", str(t["id"]) if t["id"] is not None else quoted(t["file"][:-3])),
1294 ("status", args.status),
1295 ("assignee", quoted(assignee) if assignee else ""),
1296 *blocked.items(),
1297 ]
1298 text = "---\n" + "".join(f"{k}: {v}\n" for k, v in fields if v != "") + "---\n"
1299 data = text.encode("utf-8") # before the file is opened, so a failure leaves no empty plan
1300 try:
1301 with path.open("xb") as f:
1302 f.write(data)
1303 except FileExistsError:
1304 raise TicketError(f"{path.name} exists already and is not the plan for {ref_name(t, tree)}") from None
1305 else:
1306 values = {"status": args.status, **blocked}
1307 if args.assignee is not None:
1308 values["assignee"] = quoted(args.assignee)
1309 text = edit_frontmatter(path, values)
1310 fm = parse_frontmatter(text, lenient=True)
1311 return {
1312 "plan": str(path),
1313 "created": created,
1314 **{k: fm.get(k, "") for k in PLAN_FIELDS},
1315 }
1318MIRROR_KEYS = ("ref", "tracker_id", "remote", "tracker_status", "assignee", "after")
1321def _mirror_values(item: dict, where: str) -> dict[str, str]:
1322 """The frontmatter lines one mirrored ticket sets, each validated."""
1323 values = {}
1324 for key in ("tracker_id", "remote", "assignee"):
1325 if key in item:
1326 value = item[key]
1327 if key == "tracker_id" and isinstance(value, int) and not isinstance(value, bool):
1328 value = str(value) # a tracker that numbers its items returns a number
1329 if not isinstance(value, str):
1330 raise TicketError(f"{where}: {key} must be a string")
1331 values[key] = quoted(value) if value else ""
1332 if "tracker_status" in item:
1333 values["tracker_status"] = _one_of(item["tracker_status"], STATES, where, "tracker_status")
1334 if "after" in item:
1335 if not isinstance(item["after"], list):
1336 raise TicketError(f"{where}: after must be a list")
1337 parts = []
1338 for b in item["after"]:
1339 if isinstance(b, bool) or not isinstance(b, (int, str)) or (isinstance(b, str) and ("," in b or not b)):
1340 raise TicketError(f"{where}: after takes ids and names without commas, not {b!r}")
1341 # A number stays bare, a sibling's id; a string is quoted, so a tracker id of digits stays one.
1342 parts.append(str(b) if isinstance(b, int) else quoted(b))
1343 values["after"] = f"[{', '.join(parts)}]"
1344 return values
1347def by_tracker_id(tree: dict, tracker_id: str) -> dict:
1348 """The one ticket that already carries a tracker id; nothing else about it is matched."""
1349 hits = [t for t in tree["tickets"] if t["tracker_id"] and t["tracker_id"].lower() == tracker_id.lower()]
1350 if not hits:
1351 raise NoMatch(f"no ticket carries tracker_id {tracker_id!r}")
1352 if len(hits) > 1:
1353 raise TicketError(f"tracker_id {tracker_id!r} is on more than one ticket")
1354 return hits[0]
1357def container_named(tree: dict, name: str, by_ref: bool) -> dict | None:
1358 """The container whose tracker id, or with a `ref` whose folder, is `name`: its folder and its file's status."""
1359 low = name.lower()
1360 for folder in [*tree["folders"].values(), *([tree["initiative"]] if tree["initiative"] else [])]:
1361 path = folder / f"{folder.name}.md"
1362 if not path.is_file():
1363 continue
1364 fm = parse_frontmatter(read_text(path), lenient=True)
1365 tracker_id = str(fm.get("tracker_id", "") or "")
1366 if fm.get("type") in CONTAINER_TYPES and (
1367 (tracker_id and tracker_id.lower() == low) or (by_ref and folder.name.lower() == low)
1368 ):
1369 return {"folder": folder.name, "status": str(fm.get("status", "") or "")}
1370 return None
1373def cmd_mirror(args) -> dict:
1374 """Write what the tracker returned into each ticket's leaf file; never `status`, which is the build's."""
1375 folder = _folder(args)
1376 root = project_root_for(args, folder)
1377 store = store_name(root)
1378 if store == "repo":
1379 raise StoreRefusal("store is repo: there is no tracker to mirror")
1380 items = json.load(sys.stdin)
1381 if not isinstance(items, list) or not all(isinstance(i, dict) for i in items):
1382 raise TicketError("mirror reads a JSON array of objects on stdin")
1383 folders, trees = [folder], [load_tree(folder)]
1384 if getattr(args, "backlog", None):
1385 try:
1386 trees.append(load_tree(args.backlog))
1387 folders.append(args.backlog)
1388 except (TicketError, OSError, ValueError): # a backlog that cannot be read holds no match
1389 pass
1390 work, unmatched, containers, seen = [], [], [], set()
1391 for n, item in enumerate(items, 1):
1392 name = item.get("ref", item.get("tracker_id"))
1393 where = f"item {n} ({name!r})"
1394 unknown = [k for k in item if k not in MIRROR_KEYS]
1395 if unknown:
1396 hint = "; status is the build's and is never mirrored" if "status" in unknown else ""
1397 raise TicketError(f"{where}: unknown key {unknown[0]!r}{hint}")
1398 if isinstance(name, bool) or not isinstance(name, (int, str)) or name == "":
1399 raise TicketError(f"item {n}: give a ref, or the tracker_id of a ticket that already carries it")
1400 values = _mirror_values(item, where)
1401 hit = miss = container = None
1402 for tree in trees:
1403 try:
1404 if "ref" in item and tree is not trees[0] and str(name).strip().isdigit():
1405 raise NoMatch(f"a bare number does not name a backlog ticket: {name!r}")
1406 # A tracker id alone never falls back to an entry id or a title that happens to match it.
1407 t = resolve_ticket(tree, str(name)) if "ref" in item else by_tracker_id(tree, str(name))
1408 except NoMatch as e:
1409 miss = miss or e
1410 container = container or container_named(tree, str(name), "ref" in item)
1411 continue
1412 hit = (t, tree)
1413 break
1414 if hit is None:
1415 if container:
1416 # A container's file is Sofia's to edit; the row shows what the tracker says against it.
1417 containers.append({"ref": name, **container, "tracker_status": item.get("tracker_status", "")})
1418 else:
1419 unmatched.append({"ref": name, "error": str(miss)})
1420 continue
1421 if id(hit[0]) in seen:
1422 raise TicketError(f"{where}: {ref_name(*hit)} is named twice")
1423 seen.add(id(hit[0]))
1424 work.append((*hit, values))
1425 undo, mirrored = [], []
1426 try:
1427 for t, tree, values in work:
1428 pulled = not t["file"]
1429 if pulled:
1430 path = write_leaf(t, tree, root)
1431 undo.append((path, None))
1432 else:
1433 path = tickets_dir(tree["folders"][t["epic"]]) / t["file"]
1434 undo.append((path, path.read_bytes()))
1435 edit_frontmatter(path, values)
1436 ref_out = row_ref(t, tree) if tree is trees[0] else path.name
1437 mirrored.append({"ref": ref_out, "file": path.name, "pulled": pulled, "set": sorted(values)})
1438 for f in folders:
1439 load_tree(f)
1440 except (TicketError, OSError, ValueError) as e:
1441 for path, raw in undo:
1442 try:
1443 if raw is None:
1444 path.unlink(missing_ok=True)
1445 else:
1446 path.write_bytes(raw)
1447 except OSError: # keep restoring the rest; the first failure is the one reported
1448 pass
1449 # The discovery procedure needs `unmatched` even when a known ticket's `after` named one of them.
1450 raise TicketError(f"nothing was mirrored: {e}", **({"unmatched": unmatched} if unmatched else {})) from e
1451 return {
1452 "folder": folder.name,
1453 "store": store,
1454 "mirrored": mirrored,
1455 "containers": containers,
1456 "unmatched": unmatched,
1457 }
1460TABLE_RE = re.compile(r"^[ \t]*\[\[(entry|epic)\]\][ \t]*(?:#.*)?$", re.M)
1461ID_LINE_RE = re.compile(r"^([ \t]*id[ \t]*=[ \t]*)(\S+)(.*)$", re.M)
1462AFTER_LINE_RE = re.compile(r"^([ \t]*after[ \t]*=[ \t]*)\[(.*?)\](.*)$", re.M)
1463NEXT_ID_LINE_RE = re.compile(r"^[ \t]*next_id[ \t]*=.*$", re.M)
1466def split_tables(text: str) -> tuple[str, list[str]]:
1467 """A breakdown's text as the lines before its first table and one string per `[[entry]]` or `[[epic]]`."""
1468 starts = [m.start() for m in TABLE_RE.finditer(text)]
1469 if not starts:
1470 return text, []
1471 return text[: starts[0]], [text[a:b] for a, b in zip(starts, [*starts[1:], len(text)], strict=True)]
1474def parse_table(block: str) -> tuple[str, dict]:
1475 data = tomllib.loads(block)
1476 kind = "entry" if "entry" in data else "epic"
1477 row = data[kind][0]
1478 row["id"] = _id(row.get("id"))
1479 return kind, row
1482def list_text(values: list, toml: bool) -> str:
1483 """A list as `tickets.toml` or a leaf's frontmatter reads it back: numbers bare, strings quoted."""
1484 return (
1485 "["
1486 + ", ".join(
1487 str(v) if isinstance(v, int) else (json.dumps(v, ensure_ascii=False) if toml else quoted(v)) for v in values
1488 )
1489 + "]"
1490 )
1493def set_after_line(block: str, current: list, new: list, where: str) -> str:
1494 m = AFTER_LINE_RE.search(block)
1495 try:
1496 if not m or tomllib.loads(f"after = [{m.group(2)}]")["after"] != current:
1497 raise ValueError
1498 except ValueError:
1499 raise TicketError(f"{where}: `after` must sit on one line to be rewritten") from None
1500 return block[: m.start()] + m.group(1) + list_text(new, toml=True) + m.group(3) + block[m.end() :]
1503def cmd_move(args) -> dict:
1504 """Move an entry, with its leaf file and plan, to another folder, rewriting ids and every `after` that names it."""
1505 src, dest = locate_dir(args, args.src), locate_dir(args, args.to)
1506 root = project_root_for(args, src)
1507 tree = load_tree(src)
1508 t = resolve_ticket(tree, args.ref)
1509 old = tree["folders"][t["epic"]]
1510 if old.resolve() == dest.resolve():
1511 raise TicketError(f"{ref_name(t, tree)} is already in {dest.name}")
1512 toml_old = tickets_dir(old) / BREAKDOWN
1513 head, tables = split_tables(read_text(toml_old)) if toml_old.is_file() else ("", [])
1514 parsed = [parse_table(b) for b in tables]
1515 moved = next((i for i, (kind, row) in enumerate(parsed) if kind == "entry" and row["id"] == t["id"]), None)
1516 if moved is None:
1517 raise TicketError(f"{ref_name(t, tree)} has no entry in {shown(old)}/{BREAKDOWN}; move moves entries")
1518 dest_tree = load_tree(dest)
1519 if dest_tree["scope"] is None:
1520 raise TicketError(f"{dest.name} holds epics, not entries")
1521 new_id = next_id(dest_tree, dest.name)
1522 floor = next_id(tree, old.name)
1523 same = tree["initiative"] is not None and dest.resolve().parent == tickets_dir(tree["initiative"]).resolve()
1524 old_epic_id = tree["epic_ids"].get(old.name) if same else None
1525 dest_epic_id = tree["epic_ids"].get(dest.name) if same else None
1526 slugs = {i: s for s, i in tree["epic_ids"].items()}
1527 mates = [o for o in tree["tickets"] if o["epic"] == old.name]
1529 def is_sibling(e, o) -> bool:
1530 if isinstance(e, int) and not isinstance(e, bool):
1531 return o["id"] == e
1532 return isinstance(e, str) and (
1533 (isinstance(o["id"], str) and o["id"] == e) or bool(o["file"] and e in (o["file"], o["file"][:-3]))
1534 )
1536 def names_moved(e, folder: Path) -> bool:
1537 if folder.resolve() == old.resolve() and is_sibling(e, t):
1538 return True
1539 if all(f.resolve() != folder.resolve() for f in tree["folders"].values()):
1540 return False # another initiative numbers its epics its own way
1541 m = CROSS_RE.match(e) if isinstance(e, str) else None
1542 return bool(m and slugs.get(_id(m.group(1))) == old.name and _id(m.group(2)) == t["id"])
1544 def new_ref(folder: Path, where: str):
1545 if folder.resolve() == dest.resolve():
1546 return new_id
1547 if dest_epic_id is None:
1548 raise TicketError(
1549 f"{where}: names the moved entry, but {dest.name} has no id in the initiative's {BREAKDOWN} to name it by"
1550 )
1551 return f"{dest_epic_id}.{new_id}"
1553 def own_ref(e, where: str):
1554 mate = next((o for o in mates if o is not t and is_sibling(e, o)), None)
1555 if mate is not None:
1556 if old_epic_id is None or mate["id"] is None:
1557 raise TicketError(f"{where}: after {e!r} names a ticket in {old.name} that {dest.name} cannot name")
1558 return f"{old_epic_id}.{mate['id']}"
1559 m = CROSS_RE.match(e) if isinstance(e, str) else None
1560 if m and same and slugs.get(_id(m.group(1))) == dest.name:
1561 return _id(m.group(2))
1562 return e
1564 # Every change is computed first; files are written only when all of them are known.
1565 toml_writes: dict[Path, str] = {}
1566 fm_writes: list[tuple[Path, dict]] = []
1567 rewritten = []
1568 folders = list(tree["folders"].values())
1569 if all(f.resolve() != dest.resolve() for f in folders):
1570 folders.append(dest)
1571 for folder in folders:
1572 is_old = folder.resolve() == old.resolve()
1573 home, at = tickets_dir(folder), shown(folder)
1574 path = home / BREAKDOWN
1575 if path.is_file():
1576 f_head, f_tables = (head, list(tables)) if is_old else split_tables(read_text(path))
1577 changed = False
1578 for i, block in enumerate(f_tables):
1579 kind, row = parse_table(block)
1580 if kind != "entry" or (is_old and i == moved):
1581 continue
1582 where = f"{at}/{BREAKDOWN} entry {row['id']}"
1583 after = _list(row.get("after"), where)
1584 new = [new_ref(folder, where) if names_moved(e, folder) else e for e in after]
1585 if new != after:
1586 f_tables[i] = set_after_line(block, after, new, where)
1587 rewritten.append({"where": where, "after": new})
1588 changed = True
1589 if is_old:
1590 f_tables.pop(moved)
1591 if NEXT_ID_LINE_RE.search(f_head):
1592 f_head = NEXT_ID_LINE_RE.sub(f"next_id = {floor}", f_head, count=1)
1593 else:
1594 f_head = f_head.rstrip("\n") + ("\n\n" if f_head.strip() else "") + f"next_id = {floor}\n\n"
1595 changed = True
1596 if changed:
1597 toml_writes[path] = f_head + "".join(f_tables)
1598 own = folder / f"{folder.name}.md" # an epic file's own `after` can name the entry too
1599 for path in sorted({*home.glob("*.md"), *([own] if own.is_file() else [])}):
1600 if is_old and path.parent == home and path.name == t["file"]:
1601 continue
1602 fm = parse_frontmatter(read_text(path), lenient=True)
1603 if fm.get("type") not in LEAF_TYPES and not (fm.get("type") in CONTAINER_TYPES and path == own):
1604 continue
1605 where = f"{at}/{path.name}" if path != own else f"{folder.name}/{own.name}"
1606 after = _list(fm.get("after"), where)
1607 new = [new_ref(folder, where) if names_moved(e, folder) else e for e in after]
1608 if new != after:
1609 fm_writes.append((path, {"after": list_text(new, toml=False)}))
1610 rewritten.append({"where": where, "after": new})
1611 block = tables[moved]
1612 old_home, dest_home = tickets_dir(old), tickets_dir(dest)
1613 where = f"{shown(dest)}/{BREAKDOWN} entry {new_id}"
1614 block = ID_LINE_RE.sub(lambda m: f"{m.group(1)}{new_id}{m.group(3)}", block, count=1)
1615 after = _list(parsed[moved][1].get("after"), where)
1616 new = [own_ref(e, where) for e in after]
1617 if new != after:
1618 block = set_after_line(block, after, new, where)
1619 rewritten.append({"where": where, "after": new})
1620 toml_dest = dest_home / BREAKDOWN
1621 if toml_dest in toml_writes:
1622 existing = toml_writes[toml_dest]
1623 else:
1624 existing = read_text(toml_dest) if toml_dest.is_file() else ""
1625 toml_writes[toml_dest] = (existing.rstrip("\n") + "\n\n" if existing.strip() else "") + block.strip("\n") + "\n"
1626 moves = []
1627 for name, values in (
1628 (t["file"], {"id": str(new_id), "parent": dest.name}),
1629 (t.get("plan"), {"ticket": str(new_id)}),
1630 ):
1631 if not name:
1632 continue
1633 if (dest_home / name).exists():
1634 raise TicketError(f"{shown(dest)}/{name} exists already")
1635 if name == t["file"]:
1636 fm = parse_frontmatter(read_text(old_home / name))
1637 after = _list(fm.get("after"), f"{shown(old)}/{name}")
1638 new = [own_ref(e, f"{shown(old)}/{name}") for e in after]
1639 if new != after:
1640 values["after"] = list_text(new, toml=False)
1641 rewritten.append({"where": f"{shown(dest)}/{name}", "after": new})
1642 moves.append((old_home / name, dest_home / name, values))
1644 backup: dict[Path, bytes | None] = {}
1645 made = None if dest_home.is_dir() else dest_home
1647 def keep(path: Path) -> None:
1648 if path not in backup:
1649 backup[path] = path.read_bytes() if path.exists() else None
1651 try:
1652 if made is not None:
1653 made.mkdir()
1654 for path, text in toml_writes.items():
1655 keep(path)
1656 path.write_text(text, encoding="utf-8")
1657 for path, values in fm_writes:
1658 keep(path)
1659 edit_frontmatter(path, values)
1660 for source, target, values in moves:
1661 keep(source)
1662 keep(target)
1663 raw = source.read_bytes()
1664 if source.name == t["file"]:
1665 raw = re.sub(
1666 rb"(?m)^- parent \xe2\x80\x94 [^\r\n]*",
1667 lambda _: f"- parent — {parent_reference(dest, root)}".encode(),
1668 raw,
1669 count=1,
1670 )
1671 target.write_bytes(raw)
1672 edit_frontmatter(target, values)
1673 source.unlink()
1674 for folder in (dest, old):
1675 load_tree(folder)
1676 except (TicketError, OSError, ValueError) as e:
1677 for path, raw in backup.items():
1678 try:
1679 if raw is None:
1680 path.unlink(missing_ok=True)
1681 else:
1682 path.write_bytes(raw)
1683 except OSError: # keep restoring the rest; the first failure is the one reported
1684 pass
1685 if made is not None and made.is_dir() and not any(made.iterdir()):
1686 made.rmdir()
1687 raise TicketError(f"nothing was moved: {e}") from e
1688 return {
1689 "from": old.name,
1690 "to": dest.name,
1691 "old_id": t["id"],
1692 "id": new_id,
1693 "ref": f"{dest_epic_id}.{new_id}" if dest_epic_id is not None else str(new_id),
1694 "file": t["file"],
1695 "plan": t.get("plan"),
1696 "next_id": {old.name: floor},
1697 "rewritten": rewritten,
1698 }
1701REF_HELP = """<ref> is `<epic id>.<entry id>`, an entry id inside an epic folder (one with a letter, from any
1702folder), a tracker id, a file name, or an unbroken phrase from the title that matches one ticket."""
1704DIR_HELP = """<dir> is an epic folder, a backlog folder, or an initiative folder (all its epics): a path, or a
1705folder's name under the store, the active initiative, or its `tickets/` folder. A `tickets/` folder is
1706read as the folder that holds it. Left out, it is the active initiative."""
1708BACKLOG_HELP = (
1709 "With no <dir>, a ticket the initiative does not hold is looked for in the backlog folder, "
1710 "except by a bare number, which is ambiguous there unless it is the ticket's tracker id."
1711)
1713PARENT_HELP = """A parent initiative, whose `tickets.toml` lists `[[initiative]]` tables, works only with status
1714and next; the other commands want one of its initiatives."""
1716HELP = {
1717 "next": f"""Tickets grouped by what can happen next, in build order.
1719{DIR_HELP} With no <dir>, `backlog` holds the same view of the backlog folder, each row's `ref`
1720its file name.
1722Output: `ready_to_refine` (needs full criteria first), `ready_to_start`, `in_progress`, and `blocked`; a
1723`blocked` row has `waiting_on`, its unmet prerequisites, `blocked_reason`, or `unknown`, a question to
1724settle before it starts. A prerequisite is met when it is done or in review; an epic's own gate
1725(`gated_by`) waits for that epic to be done. `unpinned_after`, `undeclared_after`, `order_conflict`, and
1726`problems` report a tree that needs fixing. Every row carries `ref`, which find resolves, `state`, and
1727`risk`; a row has `drift` when the leaf file's `after` or `hitl` differs from the entry's, and `plan_checkpoint` or
1728`done_checkpoint` when the entry sets it.
1730On a parent initiative: `initiatives`, as status gives them, `order_conflict`, and `ready`, the
1731initiatives waiting on none and not done or dropped.""",
1732 "status": f"""Every ticket in build order.
1734{DIR_HELP} With no <dir>, `backlog` holds the same view of the backlog folder, each row's `ref`
1735its file name.
1737Output: `tickets` (each with `status`, `tracker_status`, `state`, `blocks`, and `drift` when the leaf file's
1738`after` or `hitl` differs from the entry's, with both values), `counts` by state, `next_id` (the id a new
1739ticket in the folder takes: one past the highest number used, or `next_id` at the top of the folder's
1740`tickets.toml` when that is higher; on an initiative, in each `epics` row), `longest_remaining_chain`, and
1741on an initiative `epics`: one row per epic its `tickets.toml` lists, in build order, then any epic folder
1742it does not list, each with its declared `after`, and `refined`, false only when the epic file says
1743`refined: false`. A listed epic with no folder yet has `folder` null and no `status`, `refined`,
1744`gated_by`, `blocks`, or `next_id`.
1746On a parent initiative: `initiatives`, one row per initiative its `tickets.toml` lists, in build order,
1747each with `id`, `slug`, `title`, `after` (as `[{{initiative, needs}}]`), `folder` (null until the folder
1748exists beside the parent), and `waiting_on`, the initiatives it is after whose file is not `status:
1749done`; a row with a folder also has that file's `status` and `counts`, its tickets by state.
1750`order_conflict` lists an initiative after one later in the build order.""",
1751 "find": f"""The one ticket a reference names.
1753{DIR_HELP}
1754{PARENT_HELP}
1755{BACKLOG_HELP}
1756{REF_HELP}
1758Output: the ticket's row; its entry's `description`, `verify`, `references`, and `notes`, all empty once
1759the entry is pulled, when `story_file` holds them; `unknown`; its `folder`; and the absolute paths
1760`epic_file` (the file of the container it is under, null in a backlog folder), `story_file` (null until
1761the entry is pulled), and `plan` (where its plan is or goes; it may not exist yet).""",
1762 "pull": f"""Write an entry's leaf file from its entry.
1764<dir> is the epic folder and <id> the entry's id. The file is `<type>-<slug of the title>.md` in the
1765epic's `tickets/` folder: `after` and `hitl` always, other fields only when the entry sets them, no status.
1766{PARENT_HELP}""",
1767 "mark": f"""Set a ticket's status in its plan; never in its leaf file. Repo store only.
1769{DIR_HELP}
1770{PARENT_HELP}
1771{BACKLOG_HELP}
1772{REF_HELP}
1774A ticket with no plan gets one holding only frontmatter. `--blocked` sets `blocked_at` (today) and
1775`blocked_reason`; without it both are cleared.""",
1776 "mirror": f"""Write what a tracker returned into leaf files. Tracker stores only.
1778{DIR_HELP}
1779{PARENT_HELP}
1780{BACKLOG_HELP}
1782Stdin is a JSON array, one object per ticket: `ref` (or `tracker_id` alone, which matches only a ticket
1783that already carries that id) and any of `tracker_id`, `remote`, `tracker_status` ({", ".join(STATES)}),
1784`assignee`, and `after` (a list: a number is a sibling's id, a string any other prerequisite form, a
1785tracker id included). Only the keys given are written, an empty string removes the line, and `status` is
1786never written. An entry with no leaf file is pulled first. A published container, named by its folder or
1787its tracker id, is listed under `containers` with its file's `status` and the `tracker_status` given, and
1788nothing is written to it. A ticket the tree does not hold is listed under `unmatched` and the rest are
1789written; values that would leave the tree unreadable write nothing, and the error then still lists
1790`unmatched`.""",
1791 "move": f"""Move an entry to another folder, with its leaf file and plan.
1793<from> and <to> are folders as <dir> is for the other commands; <ref> names an entry in <from>.
1794{PARENT_HELP}
1795{REF_HELP}
1797The entry takes <to>'s `next_id` as its id and goes last in <to>'s `tickets.toml`; reorder it by hand when
1798build order needs it. <from> keeps `next_id` atop its `tickets.toml` so the old id never returns. The leaf
1799file and plan move with it, the file's `id` and `parent` and the plan's `ticket` set. Every `after` naming
1800the entry is rewritten (a former sibling now says `<epic id>.<id>`) and its own `after` follows; `covers`
1801is untouched. When any of it fails, nothing is written.
1803Output: `from`, `to`, `old_id`, `id`, `ref` (as find on the initiative resolves it), `file`, `plan`,
1804`next_id` (what <from> now keeps), and `rewritten`, one `where` and `after` per line rewritten.""",
1805}
1808def main() -> int:
1809 raw = argparse.RawDescriptionHelpFormatter
1810 parser = argparse.ArgumentParser(description=__doc__, formatter_class=raw)
1811 parser.add_argument(
1812 "--project-root",
1813 help="project holding _bmad/; default: walk up from the ticket folder, or the working directory with no folder",
1814 )
1815 sub = parser.add_subparsers(dest="command", required=True)
1816 p = sub.add_parser(
1817 "next",
1818 help="tickets whose prerequisites are done or in review, by state",
1819 description=HELP["next"],
1820 formatter_class=raw,
1821 )
1822 p.add_argument("dir", nargs="?", help="default: the active initiative")
1823 p.add_argument("--synced", action="store_true", help="tracker status was mirrored just now")
1824 p.set_defaults(func=cmd_next)
1825 p = sub.add_parser("status", help="every ticket resolved", description=HELP["status"], formatter_class=raw)
1826 p.add_argument("dir", nargs="?", help="default: the active initiative")
1827 p.set_defaults(func=cmd_status)
1828 p = sub.add_parser("find", help="the one ticket a reference names", description=HELP["find"], formatter_class=raw)
1829 p.add_argument("dir", nargs="?", help="default: the active initiative")
1830 p.add_argument("ref")
1831 p.set_defaults(func=cmd_find)
1832 p = sub.add_parser("pull", help="write an entry's leaf file", description=HELP["pull"], formatter_class=raw)
1833 p.add_argument("dir")
1834 p.add_argument("id")
1835 p.set_defaults(func=cmd_pull)
1836 p = sub.add_parser(
1837 "mark",
1838 help="set a ticket's status in its plan (repo store only)",
1839 description=HELP["mark"],
1840 formatter_class=raw,
1841 )
1842 p.add_argument("dir", nargs="?", help="default: the active initiative")
1843 p.add_argument("ref")
1844 p.add_argument("status", choices=STATUSES)
1845 p.add_argument("--assignee")
1846 p.add_argument(
1847 "--blocked", metavar="REASON", help="set blocked_at to today and blocked_reason; else both are cleared"
1848 )
1849 p.set_defaults(func=cmd_mark)
1850 p = sub.add_parser(
1851 "mirror",
1852 help="write what a tracker returned into leaf files (tracker stores only)",
1853 description=HELP["mirror"],
1854 formatter_class=raw,
1855 )
1856 p.add_argument("dir", nargs="?", help="default: the active initiative")
1857 p.set_defaults(func=cmd_mirror)
1858 p = sub.add_parser(
1859 "move",
1860 help="move an entry, its leaf file, and its plan to another folder",
1861 description=HELP["move"],
1862 formatter_class=raw,
1863 )
1864 p.add_argument("src", metavar="from", help="the folder holding the entry")
1865 p.add_argument("ref")
1866 p.add_argument("to", help="the folder it goes to")
1867 p.set_defaults(func=cmd_move)
1868 args = parser.parse_args()
1869 try:
1870 print(json.dumps(args.func(args), ensure_ascii=False, default=str))
1871 return 0
1872 except StoreRefusal as e:
1873 print(json.dumps({"error": str(e)}), file=sys.stderr)
1874 return 2
1875 except (TicketError, OSError, ValueError) as e:
1876 print(json.dumps({"error": str(e), **getattr(e, "data", {})}, ensure_ascii=False), file=sys.stderr)
1877 return 1
1880if __name__ == "__main__":
1881 if sys.platform == "win32":
1882 # Piped output on Windows defaults to a legacy code page, not UTF-8.
1883 sys.stdout.reconfigure(encoding="utf-8")
1884 sys.stderr.reconfigure(encoding="utf-8")
1885 raise SystemExit(main())