tasks_issues
tasks_issues — the pure half of issues.sh tasks-to-issues (MIP-0063 §5.5): read a
docs/MIPs/MIP-NNNN.tasks.md task table, say which rows already have an issue, and rewrite a
row's # cell into a link once one exists. Every gh call stays in issues.sh; nothing here
touches the network.
scripts/lib/tasks_issues.py plan <tasks.md> --repo <owner/name> [--issues <issues.json>]
scripts/lib/tasks_issues.py link <tasks.md> --repo <owner/name> --map <json> [--dry-run]
scripts/lib/tasks_issues.py --self-test
marola's twelve task files are markdown tables, not the checkbox lists GitHub spec-kit rewrites
(MIP-0063 §4.7), and the depends on column is a DAG rather than a chain — MIP-0034 has 6 ← 1,
MIP-0031 three roots, MIP-0060 two lettered roots. Edges come from that column and nowhere else:
inferring k from k-1 would serialise work that runs in parallel.
1#!/usr/bin/env python3 2"""tasks_issues — the pure half of `issues.sh tasks-to-issues` (MIP-0063 §5.5): read a 3`docs/MIPs/MIP-NNNN.tasks.md` task table, say which rows already have an issue, and rewrite a 4row's `#` cell into a link once one exists. Every `gh` call stays in `issues.sh`; nothing here 5touches the network. 6 7 scripts/lib/tasks_issues.py plan <tasks.md> --repo <owner/name> [--issues <issues.json>] 8 scripts/lib/tasks_issues.py link <tasks.md> --repo <owner/name> --map <json> [--dry-run] 9 scripts/lib/tasks_issues.py --self-test 10 11marola's twelve task files are markdown *tables*, not the checkbox lists GitHub spec-kit rewrites 12(MIP-0063 §4.7), and the `depends on` column is a DAG rather than a chain — MIP-0034 has `6 ← 1`, 13MIP-0031 three roots, MIP-0060 two lettered roots. Edges come from that column and nowhere else: 14inferring `k` from `k-1` would serialise work that runs in parallel. 15""" 16 17import argparse 18import json 19import re 20import sys 21from pathlib import Path 22 23HEADER_FIRST_CELLS = ("#", "slug") 24COLUMNS = 5 25# The `depends on` cell's "no dependency" spellings across the twelve files: en dash, em dash, 26# and the ASCII hyphen nobody has used yet but will. 27NO_DEPS = {"-", "–", "—"} 28LINKED_CELL_RE = re.compile(r"^\[(?P<id>[^\]]+)\]\((?P<url>[^)]*)\)$") 29TASK_ID_RE = re.compile(r"^[A-Za-z0-9]+$") 30MIP_FILE_RE = re.compile(r"MIP-(?P<mip>\d{4})\.tasks\.md$") 31# A `depends on` token naming another MIP's task (#462), resolved by title like a filed row. 32CROSS_DEP_RE = re.compile(r"^(?P<mip>\d{4})-T(?P<id>[A-Za-z0-9]+)$") 33 34 35class TasksError(Exception): 36 pass 37 38 39def pipe_positions(line: str) -> list[int]: 40 """The offsets of the pipes that actually separate cells. Two kinds do not: `\\|`, which is how 41 a cell spells a literal pipe (MIP-0063's own task 5 quotes the header it parses), and one 42 inside a code span, which MIP-0010 task 5 writes bare as `MAROLA_TRACES=off|mlflow`. 43 """ 44 out: list[int] = [] 45 i = 0 46 fence = 0 # the length of the backtick run that opened the current code span; 0 = outside one 47 while i < len(line): 48 c = line[i] 49 if c == "\\": 50 i += 2 51 elif c == "`": 52 j = i 53 while j < len(line) and line[j] == "`": 54 j += 1 55 run = j - i 56 if fence == 0: 57 fence = run 58 elif fence == run: 59 fence = 0 60 i = j 61 else: 62 if c == "|" and fence == 0: 63 out.append(i) 64 i += 1 65 return out 66 67 68def split_cells(line: str) -> list[str]: 69 line = line.rstrip() 70 pipes = pipe_positions(line) 71 if len(pipes) < 2 or line[: pipes[0]].strip() or line[pipes[-1] + 1 :].strip(): 72 raise TasksError(f"not a table row: {line.strip()[:60]!r}") 73 return [ 74 line[a + 1 : b].strip().replace("\\|", "|") for a, b in zip(pipes, pipes[1:], strict=False) 75 ] 76 77 78def dedup_re(mip: str, task_id: str) -> re.Pattern[str]: 79 """One task's title pattern. `\\b` both ends: it keeps task 1 out of task 10's issue (§4.7).""" 80 return re.compile(rf"\b{re.escape(mip)}-T{re.escape(task_id)}\b") 81 82 83def parse_deps(cell: str, known: set[str], where: str, mip: str = "") -> list[str]: 84 # Parenthetical commentary is prose. MIP-0011 task 11's "– (… can be built any time relative 85 # to 1-10 …)" depends on nothing, and any digit-scraping read of that cell says 1 and 10. 86 cell = re.sub(r"\([^()]*\)", " ", cell) 87 deps: list[str] = [] 88 for part in cell.split(","): 89 words = part.split() 90 if not words: 91 continue 92 # The first word only: MIP-0060 writes "N merged, 1", where "merged" is a note on N. 93 token = words[0].strip("`*_.;:") 94 if token in NO_DEPS: 95 continue 96 own = CROSS_DEP_RE.match(token) 97 if own and own.group("mip") == mip: 98 token = own.group("id") 99 if token not in known and not CROSS_DEP_RE.match(token): 100 raise TasksError( 101 f"{where}: `depends on` names {token!r}, which is not a row of this table" 102 " nor another MIP's `NNNN-TK`" 103 ) 104 if token not in deps: 105 deps.append(token) 106 return deps 107 108 109def parse_table(path: Path) -> tuple[str, list[str], list[dict]]: 110 """-> (mip number, the file's lines, one dict per task row).""" 111 m = MIP_FILE_RE.search(path.name) 112 if not m: 113 raise TasksError(f"{path.name}: not a MIP-NNNN.tasks.md file") 114 mip = m.group("mip") 115 # split("\n"), not splitlines(): splitlines() also breaks on U+2028, U+0085 and a form feed, 116 # so rejoining with "\n" would rewrite a character the file never asked us to touch. 117 lines = path.read_text().split("\n") 118 119 start = None 120 for i, line in enumerate(lines): 121 if not line.startswith("|"): 122 continue 123 try: 124 cells = split_cells(line) 125 except TasksError: 126 continue 127 if tuple(c.lower() for c in cells[:2]) == HEADER_FIRST_CELLS: 128 start = i 129 break 130 if start is None: 131 raise TasksError(f"{path}: no `| # | slug | … |` task table") 132 header = split_cells(lines[start]) 133 if len(header) != COLUMNS: 134 raise TasksError( 135 f"{path}:{start + 1}: task table has {len(header)} columns, expected {COLUMNS}" 136 ) 137 if start + 1 >= len(lines) or set(lines[start + 1].strip()) - set("|-: "): 138 raise TasksError(f"{path}:{start + 2}: expected the header's `|---|` separator") 139 140 raw: list[tuple[int, list[str]]] = [] 141 for i in range(start + 2, len(lines)): 142 if not lines[i].startswith("|"): 143 break 144 cells = split_cells(lines[i]) 145 if len(cells) != COLUMNS: 146 raise TasksError(f"{path}:{i + 1}: {len(cells)} cells, expected {COLUMNS}") 147 raw.append((i, cells)) 148 if not raw: 149 raise TasksError(f"{path}:{start + 1}: the task table has no rows") 150 151 ids: list[str] = [] 152 for i, cells in raw: 153 linked = LINKED_CELL_RE.match(cells[0]) 154 task_id = linked.group("id").strip() if linked else cells[0] 155 if not TASK_ID_RE.match(task_id): 156 raise TasksError(f"{path}:{i + 1}: {task_id!r} is not a task id") 157 if task_id in ids: 158 raise TasksError(f"{path}:{i + 1}: task {task_id} appears twice") 159 ids.append(task_id) 160 161 known = set(ids) 162 rows = [] 163 for (i, cells), task_id in zip(raw, ids, strict=True): 164 rows.append( 165 { 166 "id": task_id, 167 "line": i, 168 "linked": bool(LINKED_CELL_RE.match(cells[0])), 169 "slug": cells[1], 170 "delivers": cells[2], 171 "tests": cells[3], 172 "deps": parse_deps(cells[4], known, f"{path}:{i + 1}", mip), 173 } 174 ) 175 return mip, lines, rows 176 177 178def title_of(mip: str, row: dict) -> str: 179 return f"{mip}-T{row['id']}: {row['slug']}" 180 181 182def repo_path(path: Path) -> str: 183 # issues.sh hands over an absolute path, and one of those in a blob URL is a 404 nobody clicks. 184 root = Path(__file__).resolve().parents[2] 185 try: 186 return path.resolve().relative_to(root).as_posix() 187 except ValueError: 188 return f"docs/MIPs/{path.name}" 189 190 191def dep_label(mip: str, dep: str, cross: dict[str, int]) -> str: 192 return f"`{dep}` (#{cross[dep]})" if dep in cross else f"`{mip}-T{dep}`" 193 194 195def body_of(mip: str, path: Path, repo: str, row: dict, total: int, cross: dict[str, int]) -> str: 196 # The heading spellings are §5.4's, so a filed row is something `issues.sh ready` can read 197 # rather than one that fails rules 1 and 2 on shape alone. What the row cannot supply — 198 # area/layer/size — stays a human's, which is what keeps this command clear of §5.6's gate. 199 url = f"https://github.com/{repo}/blob/main/{repo_path(path)}" 200 deps = ", ".join(dep_label(mip, d, cross) for d in row["deps"]) or "nothing" 201 return "\n".join( 202 [ 203 "### What", 204 "", 205 f"{row['slug']} — task {row['id']} of {total} in MIP-{mip}.", 206 "", 207 "### Acceptance criteria", 208 "", 209 f"- [ ] {row['delivers']}", 210 "", 211 "### Named test", 212 "", 213 row["tests"], 214 "", 215 "---", 216 "", 217 f"[{path.name}]({url}) owns the plan; this issue owns the status (MIP-0063 §5.5). " 218 f"Depends on {deps} — wired as native `blocked by` edges from that file's " 219 "`depends on` column, never by hand.", 220 "", 221 ] 222 ) 223 224 225def find_issue(mip: str, task_id: str, issues: list[dict]) -> int | None: 226 pattern = dedup_re(mip, task_id) 227 hits = sorted({i["number"] for i in issues if pattern.search(i.get("title", ""))}) 228 if len(hits) > 1: 229 raise TasksError( 230 f"{mip}-T{task_id} is in the title of {len(hits)} issues ({hits}) — " 231 "one of them is a duplicate filing; close it before re-running" 232 ) 233 return hits[0] if hits else None 234 235 236def match_issues(mip: str, rows: list[dict], issues: list[dict]) -> dict[str, int]: 237 found = {row["id"]: find_issue(mip, row["id"], issues) for row in rows} 238 return {k: v for k, v in found.items() if v is not None} 239 240 241def match_cross(rows: list[dict], issues: list[dict]) -> dict[str, int]: 242 """Every other-MIP token -> its issue. Unfiled is an error, not a pending edge: this run 243 cannot file it, so nothing is filed until it exists (the unknown-row rule, #462).""" 244 out: dict[str, int] = {} 245 for row in rows: 246 for dep in row["deps"]: 247 m = CROSS_DEP_RE.match(dep) 248 if not m or dep in out: 249 continue 250 number = find_issue(m.group("mip"), m.group("id"), issues) 251 if number is None: 252 raise TasksError(f"`depends on` names {dep}, but no issue's title carries it") 253 out[dep] = number 254 return out 255 256 257def plan(path: Path, repo: str, issues: list[dict]) -> dict: 258 mip, _, rows = parse_table(path) 259 filed = match_issues(mip, rows, issues) 260 cross = match_cross(rows, issues) 261 return { 262 "mip": mip, 263 "path": path.as_posix(), 264 "cross": cross, 265 "rows": [ 266 { 267 **row, 268 "title": title_of(mip, row), 269 "body": body_of(mip, path, repo, row, len(rows), cross), 270 "issue": filed.get(row["id"]), 271 } 272 for row in rows 273 ], 274 } 275 276 277def replace_first_cell(line: str, text: str) -> str: 278 pipes = pipe_positions(line) 279 if len(pipes) < 2: 280 raise TasksError(f"cannot rewrite a row with fewer than two cells: {line[:60]!r}") 281 return line[: pipes[0] + 1] + text + line[pipes[1] :] 282 283 284def link(path: Path, repo: str, mapping: dict[str, int], write: bool) -> list[str]: 285 """Rewrite each unlinked `#` cell into a link to its issue. A cell that is already a link is 286 left exactly as it is — including one pointing at `h0ffmann/marola`, the pre-rename spelling 287 GitHub still redirects — because "already linked" is what makes a re-run a no-op. 288 """ 289 mip, lines, rows = parse_table(path) 290 done = [] 291 for row in rows: 292 number = mapping.get(row["id"]) 293 if row["linked"] or number is None: 294 continue 295 cell = f" [{row['id']}](https://github.com/{repo}/issues/{number}) " 296 lines[row["line"]] = replace_first_cell(lines[row["line"]], cell) 297 done.append(f"{mip}-T{row['id']} -> #{number}") 298 if done and write: 299 path.write_text("\n".join(lines)) 300 return done 301 302 303# --- self-test ---------------------------------------------------------------------------------- 304 305 306FIXTURE_HEAD = """# MIP-0034 tasks 307 308Prose above the table. 309 310| # | slug | delivers | tests (must exist before the PR) | depends on | 311|---|---|---|---|---| 312""" 313 314 315def self_test() -> int: 316 import tempfile 317 318 # 0. Cell splitting. Both spellings of a pipe-inside-a-cell are in the real files, and either 319 # one read as a separator shifts every later column: `depends on` becomes prose. 320 assert split_cells("| 1 | a | b | c | d |") == ["1", "a", "b", "c", "d"] 321 assert split_cells(r"| 1 | a \| b | c | d | – |") == ["1", "a | b", "c", "d", "–"] 322 assert split_cells("| 5 | s | `MAROLA_TRACES=off|mlflow` | t | – |") == [ 323 "5", 324 "s", 325 "`MAROLA_TRACES=off|mlflow`", 326 "t", 327 "–", 328 ] 329 assert split_cells("| 1 | ``a | b`` and `c|d` | x | y | – |")[1] == "``a | b`` and `c|d`" 330 assert replace_first_cell("| 1 | `a|b` | c |", " [1](u) ") == "| [1](u) | `a|b` | c |" 331 332 # 1. The dedup regex (§5.5). One pattern per task, `\b` at both ends. 333 assert dedup_re("0063", "001").search("0063-T001") 334 assert dedup_re("0063", "1").search("[0063-T1]") 335 assert dedup_re("0063", "1").search("0063-T1: file a MIP's task table as issues") 336 assert not dedup_re("0063", "1").search("S0063-T1") 337 assert not dedup_re("0063", "001").search("0063-T0010") 338 # The collision the trailing `\b` is actually for: MIP-0011 has tasks 1 and 11. 339 assert not dedup_re("0011", "1").search("0011-T11: the eleventh") 340 assert dedup_re("0011", "11").search("0011-T11: the eleventh") 341 # And the leading one: a different MIP whose number ends in this one's. 342 assert not dedup_re("0063", "1").search("10063-T1") 343 344 # 2. The `depends on` column: dashes, one, many, and prose that must not read as numbers. 345 known = {str(n) for n in range(1, 12)} | {"G", "N"} 346 for cell, want in [ 347 ("–", []), 348 ("—", []), 349 ("-", []), 350 ("1", ["1"]), 351 ("5, 6", ["5", "6"]), 352 ("2, 3", ["2", "3"]), 353 ("1 (the fixture carries `wind_level`)", ["1"]), 354 ("– (branches from `main`, tracing lane)", []), 355 ("– (independent spike; any time relative to 1-10, numbered last)", []), 356 ("G (fixture; soft — schema also read upstream)", ["G"]), 357 ("N merged, 1", ["N", "1"]), 358 ("1, 1", ["1"]), 359 ("0064-T4", ["0064-T4"]), 360 ("1, `0064-T4`", ["1", "0064-T4"]), 361 ("0065-T1, 1", ["1"]), 362 ]: 363 got = parse_deps(cell, known, "fixture", "0065") 364 assert got == want, (cell, got, want) 365 try: 366 parse_deps("0065-T99", known, "fixture", "0065") 367 raise AssertionError("this MIP's own long form must name a row of the table") 368 except TasksError: 369 pass 370 try: 371 parse_deps("whatever comes first", known, "fixture") 372 raise AssertionError("an unknown dependency should be an error, not a silent drop") 373 except TasksError: 374 pass 375 376 # 3. A non-linear graph round-trips, and the row rewrite is idempotent on a linked `#` cell. 377 with tempfile.TemporaryDirectory() as tmp: 378 d = Path(tmp) 379 # MIP-0034's shape: 6 depends on 1, not on 5. Numbering is not the graph. 380 f = d / "MIP-0034.tasks.md" 381 f.write_text( 382 FIXTURE_HEAD 383 + "\n".join( 384 f"| {k} | slug-{k} | delivers \\| with a pipe | test {k} | {dep} |" 385 for k, dep in [ 386 (1, "–"), 387 (2, "1"), 388 (3, "2"), 389 (4, "3"), 390 (5, "4"), 391 (6, "1"), 392 (7, "5, 6"), 393 ] 394 ) 395 + "\n\n## After\n" 396 ) 397 mip, _, rows = parse_table(f) 398 assert mip == "0034" 399 assert [r["deps"] for r in rows] == [[], ["1"], ["2"], ["3"], ["4"], ["1"], ["5", "6"]] 400 assert rows[0]["delivers"] == "delivers | with a pipe", rows[0]["delivers"] 401 assert not any(r["linked"] for r in rows) 402 403 mapping = {str(k): 500 + k for k in range(1, 8)} 404 before = f.read_text() 405 assert len(link(f, "marola-dev/marola", mapping, write=True)) == 7 406 after = f.read_text() 407 assert "| [6](https://github.com/marola-dev/marola/issues/506) |" in after, after 408 # Everything outside the `#` cells is byte-for-byte what it was, trailing newline included. 409 assert after.endswith("\n\n## After\n") 410 assert len(after.split("\n")) == len(before.split("\n")) 411 _, _, rows2 = parse_table(f) 412 assert all(r["linked"] for r in rows2) 413 assert [r["deps"] for r in rows2] == [r["deps"] for r in rows] 414 assert [r["id"] for r in rows2] == [r["id"] for r in rows] 415 # Idempotence: the second pass rewrites nothing and leaves the bytes alone. 416 assert link(f, "marola-dev/marola", mapping, write=True) == [] 417 assert link(f, "other-owner/other", {str(k): 900 for k in range(1, 8)}, write=True) == [] 418 assert f.read_text() == after 419 420 # MIP-0031's shape: three roots, two parallel branches, no row depending on its predecessor 421 # by default. 422 g = d / "MIP-0031.tasks.md" 423 g.write_text( 424 FIXTURE_HEAD.replace("0034", "0031") 425 + "\n".join( 426 f"| {k} | slug-{k} | d | t | {dep} |" 427 for k, dep in [ 428 (1, "—"), 429 (2, "1"), 430 (3, "—"), 431 (4, "—"), 432 (5, "1, 3"), 433 (6, "2, 4"), 434 ] 435 ) 436 + "\n" 437 ) 438 _, _, grows = parse_table(g) 439 assert [r["id"] for r in grows if not r["deps"]] == ["1", "3", "4"] 440 assert grows[4]["deps"] == ["1", "3"] and grows[5]["deps"] == ["2", "4"] 441 442 # 4. Dedup against a stubbed issue list: filed rows are found, the rest are None, and a 443 # double filing is refused rather than silently picking one. 444 issues = [ 445 {"number": 414, "title": "0034-T1: slug-1"}, 446 {"number": 419, "title": "0034-T6: slug-6"}, 447 {"number": 431, "title": "CI mirrors `just quality-other` by hand"}, 448 ] 449 _, _, rows34 = parse_table(f) 450 assert match_issues("0034", rows34, issues) == {"1": 414, "6": 419} 451 try: 452 match_issues("0034", rows34, issues + [{"number": 500, "title": "0034-T1: again"}]) 453 raise AssertionError("two issues for one task id should be an error") 454 except TasksError: 455 pass 456 457 # 4b. Cross-MIP tokens (#462): resolved by title, named in the body, never guessed. 458 c = d / "MIP-0065.tasks.md" 459 c.write_text(FIXTURE_HEAD + "| 1 | a | d | t | 0064-T4 |\n| 2 | b | d | t | 1, 0064-T4 |\n") 460 other = [ 461 {"number": 458, "title": "0064-T4: kroki"}, 462 {"number": 459, "title": "0064-T40: x"}, 463 ] 464 pc = plan(c, "marola-dev/marola", other) 465 assert pc["cross"] == {"0064-T4": 458}, pc["cross"] 466 assert [r["deps"] for r in pc["rows"]] == [["0064-T4"], ["1", "0064-T4"]] 467 assert "Depends on `0065-T1`, `0064-T4` (#458)" in pc["rows"][1]["body"] 468 for bad_issues in ([], other + [{"number": 460, "title": "0064-T4: again"}]): 469 try: 470 plan(c, "marola-dev/marola", bad_issues) 471 raise AssertionError(f"cross-MIP token against {bad_issues} should be an error") 472 except TasksError as exc: 473 assert "0064-T4" in str(exc), exc 474 475 p = plan(f, "marola-dev/marola", issues) 476 assert p["rows"][0]["issue"] == 414 and p["rows"][1]["issue"] is None 477 assert p["rows"][5]["title"] == "0034-T6: slug-6" 478 for heading in ("### What", "### Acceptance criteria", "### Named test"): 479 assert heading in p["rows"][5]["body"], heading 480 # A tasks file outside the checkout must not put its absolute path in the blob URL. 481 assert f"blob/main/docs/MIPs/{f.name}" in p["rows"][5]["body"], p["rows"][5]["body"] 482 483 # 5. Shape errors are errors, not skipped rows. 484 for name, text in [ 485 ("MIP-0099.tasks.md", "# no table here\n"), 486 ("MIP-0099.tasks.md", FIXTURE_HEAD + "| 1 | s | d | t |\n"), 487 ("MIP-0099.tasks.md", FIXTURE_HEAD + "| 1 | s | d | t | – |\n| 1 | s | d | t | – |\n"), 488 ("MIP-0099.tasks.md", FIXTURE_HEAD + "| 1 2 | s | d | t | – |\n"), 489 ("notes.md", FIXTURE_HEAD + "| 1 | s | d | t | – |\n"), 490 ]: 491 bad = d / name 492 bad.write_text(text) 493 try: 494 parse_table(bad) 495 raise AssertionError(f"expected TasksError for {name}: {text[-40:]!r}") 496 except TasksError: 497 pass 498 499 # 6. The repo's own twelve task files parse, and the two graphs the MIP names are the ones 500 # that come back. A new task file in a shape this parser cannot read fails here, loudly, 501 # rather than at the first live run. 502 tasks_dir = Path(__file__).resolve().parents[2] / "docs" / "MIPs" 503 files = sorted(tasks_dir.glob("MIP-*.tasks.md")) 504 assert len(files) >= 12, files 505 graphs = {} 506 for f in files: 507 mip, _, rows = parse_table(f) 508 graphs[mip] = {r["id"]: r["deps"] for r in rows} 509 assert graphs["0034"]["6"] == ["1"], graphs["0034"] 510 assert graphs["0034"]["7"] == ["5", "6"] 511 assert [k for k, v in graphs["0031"].items() if not v] == ["1", "3", "4"] 512 assert graphs["0056"]["7"] == ["4"] 513 assert graphs["0063"]["4"] == ["2", "3"] and graphs["0063"]["6"] == ["4"] 514 assert graphs["0011"]["11"] == [], "MIP-0011 task 11's prose names 1-10 and depends on none" 515 assert graphs["0060"]["2"] == ["N", "1"], graphs["0060"] 516 _, _, rows63 = parse_table(tasks_dir / "MIP-0063.tasks.md") 517 assert all(r["linked"] for r in rows63), "MIP-0063's rows were linked by hand on 2026-09-27" 518 assert link(tasks_dir / "MIP-0063.tasks.md", "marola-dev/marola", {"1": 1}, write=False) == [] 519 520 print("tasks_issues self-test: PASSED") 521 return 0 522 523 524def main(argv: list[str]) -> int: 525 ap = argparse.ArgumentParser( 526 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 527 ) 528 ap.add_argument("command", nargs="?", choices=["plan", "link"]) 529 ap.add_argument("file", nargs="?", type=Path) 530 ap.add_argument("--repo", default="", help="owner/name, for the links and the body footer") 531 ap.add_argument("--issues", type=Path, help="JSON array of {number,title} to dedup against") 532 ap.add_argument("--map", default="{}", help="JSON object of task id -> issue number") 533 ap.add_argument("--dry-run", action="store_true", help="link: report, write nothing") 534 ap.add_argument("--self-test", action="store_true") 535 args = ap.parse_args(argv) 536 537 if args.self_test: 538 return self_test() 539 if not args.command or not args.file: 540 ap.print_help() 541 return 2 542 try: 543 if not args.repo: 544 raise TasksError("--repo owner/name is required") 545 if args.command == "plan": 546 issues = json.loads(args.issues.read_text()) if args.issues else [] 547 json.dump(plan(args.file, args.repo, issues), sys.stdout) 548 print() 549 else: 550 for line in link(args.file, args.repo, json.loads(args.map), not args.dry_run): 551 print(line) 552 except TasksError as exc: 553 print(f"tasks_issues: {exc}", file=sys.stderr) 554 return 1 555 return 0 556 557 558if __name__ == "__main__": 559 sys.exit(main(sys.argv[1:]))
Common base class for all non-exit exceptions.
40def pipe_positions(line: str) -> list[int]: 41 """The offsets of the pipes that actually separate cells. Two kinds do not: `\\|`, which is how 42 a cell spells a literal pipe (MIP-0063's own task 5 quotes the header it parses), and one 43 inside a code span, which MIP-0010 task 5 writes bare as `MAROLA_TRACES=off|mlflow`. 44 """ 45 out: list[int] = [] 46 i = 0 47 fence = 0 # the length of the backtick run that opened the current code span; 0 = outside one 48 while i < len(line): 49 c = line[i] 50 if c == "\\": 51 i += 2 52 elif c == "`": 53 j = i 54 while j < len(line) and line[j] == "`": 55 j += 1 56 run = j - i 57 if fence == 0: 58 fence = run 59 elif fence == run: 60 fence = 0 61 i = j 62 else: 63 if c == "|" and fence == 0: 64 out.append(i) 65 i += 1 66 return out
The offsets of the pipes that actually separate cells. Two kinds do not: \|, which is how
a cell spells a literal pipe (MIP-0063's own task 5 quotes the header it parses), and one
inside a code span, which MIP-0010 task 5 writes bare as MAROLA_TRACES=off|mlflow.
69def split_cells(line: str) -> list[str]: 70 line = line.rstrip() 71 pipes = pipe_positions(line) 72 if len(pipes) < 2 or line[: pipes[0]].strip() or line[pipes[-1] + 1 :].strip(): 73 raise TasksError(f"not a table row: {line.strip()[:60]!r}") 74 return [ 75 line[a + 1 : b].strip().replace("\\|", "|") for a, b in zip(pipes, pipes[1:], strict=False) 76 ]
79def dedup_re(mip: str, task_id: str) -> re.Pattern[str]: 80 """One task's title pattern. `\\b` both ends: it keeps task 1 out of task 10's issue (§4.7).""" 81 return re.compile(rf"\b{re.escape(mip)}-T{re.escape(task_id)}\b")
One task's title pattern. \b both ends: it keeps task 1 out of task 10's issue (§4.7).
84def parse_deps(cell: str, known: set[str], where: str, mip: str = "") -> list[str]: 85 # Parenthetical commentary is prose. MIP-0011 task 11's "– (… can be built any time relative 86 # to 1-10 …)" depends on nothing, and any digit-scraping read of that cell says 1 and 10. 87 cell = re.sub(r"\([^()]*\)", " ", cell) 88 deps: list[str] = [] 89 for part in cell.split(","): 90 words = part.split() 91 if not words: 92 continue 93 # The first word only: MIP-0060 writes "N merged, 1", where "merged" is a note on N. 94 token = words[0].strip("`*_.;:") 95 if token in NO_DEPS: 96 continue 97 own = CROSS_DEP_RE.match(token) 98 if own and own.group("mip") == mip: 99 token = own.group("id") 100 if token not in known and not CROSS_DEP_RE.match(token): 101 raise TasksError( 102 f"{where}: `depends on` names {token!r}, which is not a row of this table" 103 " nor another MIP's `NNNN-TK`" 104 ) 105 if token not in deps: 106 deps.append(token) 107 return deps
110def parse_table(path: Path) -> tuple[str, list[str], list[dict]]: 111 """-> (mip number, the file's lines, one dict per task row).""" 112 m = MIP_FILE_RE.search(path.name) 113 if not m: 114 raise TasksError(f"{path.name}: not a MIP-NNNN.tasks.md file") 115 mip = m.group("mip") 116 # split("\n"), not splitlines(): splitlines() also breaks on U+2028, U+0085 and a form feed, 117 # so rejoining with "\n" would rewrite a character the file never asked us to touch. 118 lines = path.read_text().split("\n") 119 120 start = None 121 for i, line in enumerate(lines): 122 if not line.startswith("|"): 123 continue 124 try: 125 cells = split_cells(line) 126 except TasksError: 127 continue 128 if tuple(c.lower() for c in cells[:2]) == HEADER_FIRST_CELLS: 129 start = i 130 break 131 if start is None: 132 raise TasksError(f"{path}: no `| # | slug | … |` task table") 133 header = split_cells(lines[start]) 134 if len(header) != COLUMNS: 135 raise TasksError( 136 f"{path}:{start + 1}: task table has {len(header)} columns, expected {COLUMNS}" 137 ) 138 if start + 1 >= len(lines) or set(lines[start + 1].strip()) - set("|-: "): 139 raise TasksError(f"{path}:{start + 2}: expected the header's `|---|` separator") 140 141 raw: list[tuple[int, list[str]]] = [] 142 for i in range(start + 2, len(lines)): 143 if not lines[i].startswith("|"): 144 break 145 cells = split_cells(lines[i]) 146 if len(cells) != COLUMNS: 147 raise TasksError(f"{path}:{i + 1}: {len(cells)} cells, expected {COLUMNS}") 148 raw.append((i, cells)) 149 if not raw: 150 raise TasksError(f"{path}:{start + 1}: the task table has no rows") 151 152 ids: list[str] = [] 153 for i, cells in raw: 154 linked = LINKED_CELL_RE.match(cells[0]) 155 task_id = linked.group("id").strip() if linked else cells[0] 156 if not TASK_ID_RE.match(task_id): 157 raise TasksError(f"{path}:{i + 1}: {task_id!r} is not a task id") 158 if task_id in ids: 159 raise TasksError(f"{path}:{i + 1}: task {task_id} appears twice") 160 ids.append(task_id) 161 162 known = set(ids) 163 rows = [] 164 for (i, cells), task_id in zip(raw, ids, strict=True): 165 rows.append( 166 { 167 "id": task_id, 168 "line": i, 169 "linked": bool(LINKED_CELL_RE.match(cells[0])), 170 "slug": cells[1], 171 "delivers": cells[2], 172 "tests": cells[3], 173 "deps": parse_deps(cells[4], known, f"{path}:{i + 1}", mip), 174 } 175 ) 176 return mip, lines, rows
-> (mip number, the file's lines, one dict per task row).
183def repo_path(path: Path) -> str: 184 # issues.sh hands over an absolute path, and one of those in a blob URL is a 404 nobody clicks. 185 root = Path(__file__).resolve().parents[2] 186 try: 187 return path.resolve().relative_to(root).as_posix() 188 except ValueError: 189 return f"docs/MIPs/{path.name}"
196def body_of(mip: str, path: Path, repo: str, row: dict, total: int, cross: dict[str, int]) -> str: 197 # The heading spellings are §5.4's, so a filed row is something `issues.sh ready` can read 198 # rather than one that fails rules 1 and 2 on shape alone. What the row cannot supply — 199 # area/layer/size — stays a human's, which is what keeps this command clear of §5.6's gate. 200 url = f"https://github.com/{repo}/blob/main/{repo_path(path)}" 201 deps = ", ".join(dep_label(mip, d, cross) for d in row["deps"]) or "nothing" 202 return "\n".join( 203 [ 204 "### What", 205 "", 206 f"{row['slug']} — task {row['id']} of {total} in MIP-{mip}.", 207 "", 208 "### Acceptance criteria", 209 "", 210 f"- [ ] {row['delivers']}", 211 "", 212 "### Named test", 213 "", 214 row["tests"], 215 "", 216 "---", 217 "", 218 f"[{path.name}]({url}) owns the plan; this issue owns the status (MIP-0063 §5.5). " 219 f"Depends on {deps} — wired as native `blocked by` edges from that file's " 220 "`depends on` column, never by hand.", 221 "", 222 ] 223 )
226def find_issue(mip: str, task_id: str, issues: list[dict]) -> int | None: 227 pattern = dedup_re(mip, task_id) 228 hits = sorted({i["number"] for i in issues if pattern.search(i.get("title", ""))}) 229 if len(hits) > 1: 230 raise TasksError( 231 f"{mip}-T{task_id} is in the title of {len(hits)} issues ({hits}) — " 232 "one of them is a duplicate filing; close it before re-running" 233 ) 234 return hits[0] if hits else None
242def match_cross(rows: list[dict], issues: list[dict]) -> dict[str, int]: 243 """Every other-MIP token -> its issue. Unfiled is an error, not a pending edge: this run 244 cannot file it, so nothing is filed until it exists (the unknown-row rule, #462).""" 245 out: dict[str, int] = {} 246 for row in rows: 247 for dep in row["deps"]: 248 m = CROSS_DEP_RE.match(dep) 249 if not m or dep in out: 250 continue 251 number = find_issue(m.group("mip"), m.group("id"), issues) 252 if number is None: 253 raise TasksError(f"`depends on` names {dep}, but no issue's title carries it") 254 out[dep] = number 255 return out
Every other-MIP token -> its issue. Unfiled is an error, not a pending edge: this run cannot file it, so nothing is filed until it exists (the unknown-row rule, #462).
258def plan(path: Path, repo: str, issues: list[dict]) -> dict: 259 mip, _, rows = parse_table(path) 260 filed = match_issues(mip, rows, issues) 261 cross = match_cross(rows, issues) 262 return { 263 "mip": mip, 264 "path": path.as_posix(), 265 "cross": cross, 266 "rows": [ 267 { 268 **row, 269 "title": title_of(mip, row), 270 "body": body_of(mip, path, repo, row, len(rows), cross), 271 "issue": filed.get(row["id"]), 272 } 273 for row in rows 274 ], 275 }
285def link(path: Path, repo: str, mapping: dict[str, int], write: bool) -> list[str]: 286 """Rewrite each unlinked `#` cell into a link to its issue. A cell that is already a link is 287 left exactly as it is — including one pointing at `h0ffmann/marola`, the pre-rename spelling 288 GitHub still redirects — because "already linked" is what makes a re-run a no-op. 289 """ 290 mip, lines, rows = parse_table(path) 291 done = [] 292 for row in rows: 293 number = mapping.get(row["id"]) 294 if row["linked"] or number is None: 295 continue 296 cell = f" [{row['id']}](https://github.com/{repo}/issues/{number}) " 297 lines[row["line"]] = replace_first_cell(lines[row["line"]], cell) 298 done.append(f"{mip}-T{row['id']} -> #{number}") 299 if done and write: 300 path.write_text("\n".join(lines)) 301 return done
Rewrite each unlinked # cell into a link to its issue. A cell that is already a link is
left exactly as it is — including one pointing at h0ffmann/marola, the pre-rename spelling
GitHub still redirects — because "already linked" is what makes a re-run a no-op.
316def self_test() -> int: 317 import tempfile 318 319 # 0. Cell splitting. Both spellings of a pipe-inside-a-cell are in the real files, and either 320 # one read as a separator shifts every later column: `depends on` becomes prose. 321 assert split_cells("| 1 | a | b | c | d |") == ["1", "a", "b", "c", "d"] 322 assert split_cells(r"| 1 | a \| b | c | d | – |") == ["1", "a | b", "c", "d", "–"] 323 assert split_cells("| 5 | s | `MAROLA_TRACES=off|mlflow` | t | – |") == [ 324 "5", 325 "s", 326 "`MAROLA_TRACES=off|mlflow`", 327 "t", 328 "–", 329 ] 330 assert split_cells("| 1 | ``a | b`` and `c|d` | x | y | – |")[1] == "``a | b`` and `c|d`" 331 assert replace_first_cell("| 1 | `a|b` | c |", " [1](u) ") == "| [1](u) | `a|b` | c |" 332 333 # 1. The dedup regex (§5.5). One pattern per task, `\b` at both ends. 334 assert dedup_re("0063", "001").search("0063-T001") 335 assert dedup_re("0063", "1").search("[0063-T1]") 336 assert dedup_re("0063", "1").search("0063-T1: file a MIP's task table as issues") 337 assert not dedup_re("0063", "1").search("S0063-T1") 338 assert not dedup_re("0063", "001").search("0063-T0010") 339 # The collision the trailing `\b` is actually for: MIP-0011 has tasks 1 and 11. 340 assert not dedup_re("0011", "1").search("0011-T11: the eleventh") 341 assert dedup_re("0011", "11").search("0011-T11: the eleventh") 342 # And the leading one: a different MIP whose number ends in this one's. 343 assert not dedup_re("0063", "1").search("10063-T1") 344 345 # 2. The `depends on` column: dashes, one, many, and prose that must not read as numbers. 346 known = {str(n) for n in range(1, 12)} | {"G", "N"} 347 for cell, want in [ 348 ("–", []), 349 ("—", []), 350 ("-", []), 351 ("1", ["1"]), 352 ("5, 6", ["5", "6"]), 353 ("2, 3", ["2", "3"]), 354 ("1 (the fixture carries `wind_level`)", ["1"]), 355 ("– (branches from `main`, tracing lane)", []), 356 ("– (independent spike; any time relative to 1-10, numbered last)", []), 357 ("G (fixture; soft — schema also read upstream)", ["G"]), 358 ("N merged, 1", ["N", "1"]), 359 ("1, 1", ["1"]), 360 ("0064-T4", ["0064-T4"]), 361 ("1, `0064-T4`", ["1", "0064-T4"]), 362 ("0065-T1, 1", ["1"]), 363 ]: 364 got = parse_deps(cell, known, "fixture", "0065") 365 assert got == want, (cell, got, want) 366 try: 367 parse_deps("0065-T99", known, "fixture", "0065") 368 raise AssertionError("this MIP's own long form must name a row of the table") 369 except TasksError: 370 pass 371 try: 372 parse_deps("whatever comes first", known, "fixture") 373 raise AssertionError("an unknown dependency should be an error, not a silent drop") 374 except TasksError: 375 pass 376 377 # 3. A non-linear graph round-trips, and the row rewrite is idempotent on a linked `#` cell. 378 with tempfile.TemporaryDirectory() as tmp: 379 d = Path(tmp) 380 # MIP-0034's shape: 6 depends on 1, not on 5. Numbering is not the graph. 381 f = d / "MIP-0034.tasks.md" 382 f.write_text( 383 FIXTURE_HEAD 384 + "\n".join( 385 f"| {k} | slug-{k} | delivers \\| with a pipe | test {k} | {dep} |" 386 for k, dep in [ 387 (1, "–"), 388 (2, "1"), 389 (3, "2"), 390 (4, "3"), 391 (5, "4"), 392 (6, "1"), 393 (7, "5, 6"), 394 ] 395 ) 396 + "\n\n## After\n" 397 ) 398 mip, _, rows = parse_table(f) 399 assert mip == "0034" 400 assert [r["deps"] for r in rows] == [[], ["1"], ["2"], ["3"], ["4"], ["1"], ["5", "6"]] 401 assert rows[0]["delivers"] == "delivers | with a pipe", rows[0]["delivers"] 402 assert not any(r["linked"] for r in rows) 403 404 mapping = {str(k): 500 + k for k in range(1, 8)} 405 before = f.read_text() 406 assert len(link(f, "marola-dev/marola", mapping, write=True)) == 7 407 after = f.read_text() 408 assert "| [6](https://github.com/marola-dev/marola/issues/506) |" in after, after 409 # Everything outside the `#` cells is byte-for-byte what it was, trailing newline included. 410 assert after.endswith("\n\n## After\n") 411 assert len(after.split("\n")) == len(before.split("\n")) 412 _, _, rows2 = parse_table(f) 413 assert all(r["linked"] for r in rows2) 414 assert [r["deps"] for r in rows2] == [r["deps"] for r in rows] 415 assert [r["id"] for r in rows2] == [r["id"] for r in rows] 416 # Idempotence: the second pass rewrites nothing and leaves the bytes alone. 417 assert link(f, "marola-dev/marola", mapping, write=True) == [] 418 assert link(f, "other-owner/other", {str(k): 900 for k in range(1, 8)}, write=True) == [] 419 assert f.read_text() == after 420 421 # MIP-0031's shape: three roots, two parallel branches, no row depending on its predecessor 422 # by default. 423 g = d / "MIP-0031.tasks.md" 424 g.write_text( 425 FIXTURE_HEAD.replace("0034", "0031") 426 + "\n".join( 427 f"| {k} | slug-{k} | d | t | {dep} |" 428 for k, dep in [ 429 (1, "—"), 430 (2, "1"), 431 (3, "—"), 432 (4, "—"), 433 (5, "1, 3"), 434 (6, "2, 4"), 435 ] 436 ) 437 + "\n" 438 ) 439 _, _, grows = parse_table(g) 440 assert [r["id"] for r in grows if not r["deps"]] == ["1", "3", "4"] 441 assert grows[4]["deps"] == ["1", "3"] and grows[5]["deps"] == ["2", "4"] 442 443 # 4. Dedup against a stubbed issue list: filed rows are found, the rest are None, and a 444 # double filing is refused rather than silently picking one. 445 issues = [ 446 {"number": 414, "title": "0034-T1: slug-1"}, 447 {"number": 419, "title": "0034-T6: slug-6"}, 448 {"number": 431, "title": "CI mirrors `just quality-other` by hand"}, 449 ] 450 _, _, rows34 = parse_table(f) 451 assert match_issues("0034", rows34, issues) == {"1": 414, "6": 419} 452 try: 453 match_issues("0034", rows34, issues + [{"number": 500, "title": "0034-T1: again"}]) 454 raise AssertionError("two issues for one task id should be an error") 455 except TasksError: 456 pass 457 458 # 4b. Cross-MIP tokens (#462): resolved by title, named in the body, never guessed. 459 c = d / "MIP-0065.tasks.md" 460 c.write_text(FIXTURE_HEAD + "| 1 | a | d | t | 0064-T4 |\n| 2 | b | d | t | 1, 0064-T4 |\n") 461 other = [ 462 {"number": 458, "title": "0064-T4: kroki"}, 463 {"number": 459, "title": "0064-T40: x"}, 464 ] 465 pc = plan(c, "marola-dev/marola", other) 466 assert pc["cross"] == {"0064-T4": 458}, pc["cross"] 467 assert [r["deps"] for r in pc["rows"]] == [["0064-T4"], ["1", "0064-T4"]] 468 assert "Depends on `0065-T1`, `0064-T4` (#458)" in pc["rows"][1]["body"] 469 for bad_issues in ([], other + [{"number": 460, "title": "0064-T4: again"}]): 470 try: 471 plan(c, "marola-dev/marola", bad_issues) 472 raise AssertionError(f"cross-MIP token against {bad_issues} should be an error") 473 except TasksError as exc: 474 assert "0064-T4" in str(exc), exc 475 476 p = plan(f, "marola-dev/marola", issues) 477 assert p["rows"][0]["issue"] == 414 and p["rows"][1]["issue"] is None 478 assert p["rows"][5]["title"] == "0034-T6: slug-6" 479 for heading in ("### What", "### Acceptance criteria", "### Named test"): 480 assert heading in p["rows"][5]["body"], heading 481 # A tasks file outside the checkout must not put its absolute path in the blob URL. 482 assert f"blob/main/docs/MIPs/{f.name}" in p["rows"][5]["body"], p["rows"][5]["body"] 483 484 # 5. Shape errors are errors, not skipped rows. 485 for name, text in [ 486 ("MIP-0099.tasks.md", "# no table here\n"), 487 ("MIP-0099.tasks.md", FIXTURE_HEAD + "| 1 | s | d | t |\n"), 488 ("MIP-0099.tasks.md", FIXTURE_HEAD + "| 1 | s | d | t | – |\n| 1 | s | d | t | – |\n"), 489 ("MIP-0099.tasks.md", FIXTURE_HEAD + "| 1 2 | s | d | t | – |\n"), 490 ("notes.md", FIXTURE_HEAD + "| 1 | s | d | t | – |\n"), 491 ]: 492 bad = d / name 493 bad.write_text(text) 494 try: 495 parse_table(bad) 496 raise AssertionError(f"expected TasksError for {name}: {text[-40:]!r}") 497 except TasksError: 498 pass 499 500 # 6. The repo's own twelve task files parse, and the two graphs the MIP names are the ones 501 # that come back. A new task file in a shape this parser cannot read fails here, loudly, 502 # rather than at the first live run. 503 tasks_dir = Path(__file__).resolve().parents[2] / "docs" / "MIPs" 504 files = sorted(tasks_dir.glob("MIP-*.tasks.md")) 505 assert len(files) >= 12, files 506 graphs = {} 507 for f in files: 508 mip, _, rows = parse_table(f) 509 graphs[mip] = {r["id"]: r["deps"] for r in rows} 510 assert graphs["0034"]["6"] == ["1"], graphs["0034"] 511 assert graphs["0034"]["7"] == ["5", "6"] 512 assert [k for k, v in graphs["0031"].items() if not v] == ["1", "3", "4"] 513 assert graphs["0056"]["7"] == ["4"] 514 assert graphs["0063"]["4"] == ["2", "3"] and graphs["0063"]["6"] == ["4"] 515 assert graphs["0011"]["11"] == [], "MIP-0011 task 11's prose names 1-10 and depends on none" 516 assert graphs["0060"]["2"] == ["N", "1"], graphs["0060"] 517 _, _, rows63 = parse_table(tasks_dir / "MIP-0063.tasks.md") 518 assert all(r["linked"] for r in rows63), "MIP-0063's rows were linked by hand on 2026-09-27" 519 assert link(tasks_dir / "MIP-0063.tasks.md", "marola-dev/marola", {"1": 1}, write=False) == [] 520 521 print("tasks_issues self-test: PASSED") 522 return 0
525def main(argv: list[str]) -> int: 526 ap = argparse.ArgumentParser( 527 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 528 ) 529 ap.add_argument("command", nargs="?", choices=["plan", "link"]) 530 ap.add_argument("file", nargs="?", type=Path) 531 ap.add_argument("--repo", default="", help="owner/name, for the links and the body footer") 532 ap.add_argument("--issues", type=Path, help="JSON array of {number,title} to dedup against") 533 ap.add_argument("--map", default="{}", help="JSON object of task id -> issue number") 534 ap.add_argument("--dry-run", action="store_true", help="link: report, write nothing") 535 ap.add_argument("--self-test", action="store_true") 536 args = ap.parse_args(argv) 537 538 if args.self_test: 539 return self_test() 540 if not args.command or not args.file: 541 ap.print_help() 542 return 2 543 try: 544 if not args.repo: 545 raise TasksError("--repo owner/name is required") 546 if args.command == "plan": 547 issues = json.loads(args.issues.read_text()) if args.issues else [] 548 json.dump(plan(args.file, args.repo, issues), sys.stdout) 549 print() 550 else: 551 for line in link(args.file, args.repo, json.loads(args.map), not args.dry_run): 552 print(line) 553 except TasksError as exc: 554 print(f"tasks_issues: {exc}", file=sys.stderr) 555 return 1 556 return 0