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:]))
HEADER_FIRST_CELLS = ('#', 'slug')
COLUMNS = 5
NO_DEPS = {'—', '-', '–'}
LINKED_CELL_RE = re.compile('^\\[(?P<id>[^\\]]+)\\]\\((?P<url>[^)]*)\\)$')
TASK_ID_RE = re.compile('^[A-Za-z0-9]+$')
MIP_FILE_RE = re.compile('MIP-(?P<mip>\\d{4})\\.tasks\\.md$')
CROSS_DEP_RE = re.compile('^(?P<mip>\\d{4})-T(?P<id>[A-Za-z0-9]+)$')
class TasksError(builtins.Exception):
36class TasksError(Exception):
37    pass

Common base class for all non-exit exceptions.

def pipe_positions(line: str) -> list[int]:
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.

def split_cells(line: str) -> list[str]:
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    ]
def dedup_re(mip: str, task_id: str) -> re.Pattern[str]:
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).

def parse_deps(cell: str, known: set[str], where: str, mip: str = '') -> list[str]:
 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
def parse_table(path: pathlib.Path) -> tuple[str, list[str], list[dict]]:
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).

def title_of(mip: str, row: dict) -> str:
179def title_of(mip: str, row: dict) -> str:
180    return f"{mip}-T{row['id']}: {row['slug']}"
def repo_path(path: pathlib.Path) -> str:
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}"
def dep_label(mip: str, dep: str, cross: dict[str, int]) -> str:
192def dep_label(mip: str, dep: str, cross: dict[str, int]) -> str:
193    return f"`{dep}` (#{cross[dep]})" if dep in cross else f"`{mip}-T{dep}`"
def body_of( mip: str, path: pathlib.Path, repo: str, row: dict, total: int, cross: dict[str, int]) -> str:
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    )
def find_issue(mip: str, task_id: str, issues: list[dict]) -> int | None:
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
def match_issues(mip: str, rows: list[dict], issues: list[dict]) -> dict[str, int]:
237def match_issues(mip: str, rows: list[dict], issues: list[dict]) -> dict[str, int]:
238    found = {row["id"]: find_issue(mip, row["id"], issues) for row in rows}
239    return {k: v for k, v in found.items() if v is not None}
def match_cross(rows: list[dict], issues: list[dict]) -> dict[str, int]:
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).

def plan(path: pathlib.Path, repo: str, issues: list[dict]) -> dict:
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    }
def replace_first_cell(line: str, text: str) -> str:
278def replace_first_cell(line: str, text: str) -> str:
279    pipes = pipe_positions(line)
280    if len(pipes) < 2:
281        raise TasksError(f"cannot rewrite a row with fewer than two cells: {line[:60]!r}")
282    return line[: pipes[0] + 1] + text + line[pipes[1] :]
FIXTURE_HEAD = '# MIP-0034 tasks\n\nProse above the table.\n\n| # | slug | delivers | tests (must exist before the PR) | depends on |\n|---|---|---|---|---|\n'
def self_test() -> int:
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
def main(argv: list[str]) -> int:
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