From 62c8ff976d2affaf76a8d5d93898c5d7ae2c69e8 Mon Sep 17 00:00:00 2001 From: naudachu Date: Mon, 10 Aug 2026 15:41:15 +0500 Subject: [PATCH] feat: tick in-body checkboxes from a domain script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A checkbox is the one part of a body that is state and not prose. Everything else is written once; boxes get ticked as the work goes, and until now the only ways to tick one were a human with an editor or a model rewriting the whole body. The second is worse: the rewrite re-flows lines and re-words sentences, so the issue's diff swells around a change that means one character. Progress was invisible too — issue_index.py builds INDEX.md from metadata and never looked inside a body, so "3 of 7 done" required opening the file. All three pieces are domain: a checkbox is body syntax, which is part of the answer to "what is an issue". The parser goes in issue.py so the sync layer can reuse it instead of redefining the format on its own side. issue.py gains checkboxes(text) -> [Checkbox(index, line, end_line, checked, text, section)], plus set_checkbox(text, item, checked) and checkbox_progress(text). All pure, no I/O, importable from another layer. The scan covers the whole text, in any section: the type/feature template keeps child issues as checkboxes under `## Issues`, so binding the parser to `## Acceptance criteria` would silently lose half of them; the heading is recorded, never required. Only a marker line opens an item, so a wrapped continuation line belongs to the item above it rather than counting as one of its own. A `- [ ]` inside a code fence is an example of the markup and is skipped. Line numbers are relative to the text given, which is what lets a caller work on a body or on a whole file. issue_ac.py lists the items numbered, grouped by heading, and ticks one by number or by substring. An ambiguous substring is an error that prints the matches — a coin flip would tick the wrong box and look like it worked. It patches the file rather than round-tripping through Issue.to_text(), so exactly one character changes: metadata order, wording, wrapping, trailing whitespace and CRLF endings all come back byte for byte, proven by a diff in the tests. INDEX.md gains a progress column: `3/7` for an issue with checkboxes, blank for one without. Counted off the body at build time and stored in no field — a second copy of the state would be wrong by the next edit. issue_check.py is unchanged and stays that way on purpose: an unticked box is work not done yet, not a malformed issue, and validate() carries a comment saying so. Delivering a tick to the tracker is out of scope — that is push.py --update in /tea:sync. format.md gets one clarifying bullet. It said acceptance criteria are checkboxes but never said what a checkbox is, so the parser had to settle questions the format left open: any section, wrapped items, fenced examples. Those rules are now written down where the parser and the sync layer can both point at them. tests/ is new, and is the convention: plain stdlib unittest, no pytest and no third-party deps, since the code under test may not have dependencies either. Scripts are imported via sys.path.insert and every fixture is built in a TemporaryDirectory, never in tmp/. python3 -m unittest discover -s tests -v 32 tests, OK skills/issue/scripts/ still imports stdlib only, with no subprocess. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 12 +- README.md | 4 +- agents/tea-runner.md | 9 +- skills/issue/SKILL.md | 42 ++- skills/issue/references/format.md | 7 + skills/issue/scripts/issue.py | 126 +++++++++ skills/issue/scripts/issue_ac.py | 134 +++++++++ skills/issue/scripts/issue_index.py | 26 +- tests/test_checkboxes.py | 423 ++++++++++++++++++++++++++++ 9 files changed, 770 insertions(+), 13 deletions(-) create mode 100644 skills/issue/scripts/issue_ac.py create mode 100644 tests/test_checkboxes.py diff --git a/AGENTS.md b/AGENTS.md index 4c9b2c1..e8cb10d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,9 +47,11 @@ the domain layer, it is in the wrong place. - `skills/issue` — issues as units of work (`/tea:issue`), entirely offline - `references/format.md` — canonical issue format; single source of truth - `scripts/issue.py` — domain module: slug identity, parse/render, validation, - taxonomy, dependency graph + taxonomy, dependency graph, body checkboxes - `scripts/issue_new.py` — create a local issue from its type template - `scripts/issue_check.py` — validate against the format + - `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or + substring, changing exactly one character of the file - `scripts/issue_tree.py` — draw the dependency graph - `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md` - `skills/sync` — move issues between the local store and Gitea (`/tea:sync`) @@ -68,6 +70,14 @@ the domain layer, it is in the wrong place. - `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations that don't use the pinned login; `agents-sync` keeps every directory canonical (`AGENTS.md` real file, `CLAUDE.md` symlink to it) +- `tests/` — stdlib `unittest`, no pytest and no third-party deps: the scripts + under test may not have dependencies, so neither may their tests. Scripts are + imported via `sys.path.insert` (`skills/*/scripts/` are not packages), and + fixtures are built in a `tempfile.TemporaryDirectory()` — never in `tmp/`. + + ```bash + python3 -m unittest discover -s tests -v + ``` ## Local issue store diff --git a/README.md b/README.md index 152249d..ea2adfe 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,11 @@ skills/ references/format.md canonical issue format (identity, types, templates) scripts/ Python 3, stdlib only, no network: issue.py domain module: slug identity, parse/render, - validation, taxonomy, dependency graph + validation, taxonomy, dependency graph, + body checkboxes issue_new.py create a local issue from its type template issue_check.py validate against the format + issue_ac.py list the body's checkboxes; tick one issue_tree.py draw the dependency graph issue_index.py rebuild tmp/issues/INDEX.md sync/ /tea:sync — the bridge to Gitea diff --git a/agents/tea-runner.md b/agents/tea-runner.md index df6cc2a..46dd7ce 100644 --- a/agents/tea-runner.md +++ b/agents/tea-runner.md @@ -30,7 +30,8 @@ to fill the gap yourself. Load the skill, do not remember the flags: - `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `remote.py`, `labels.py` -- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`, `issue_new.py` +- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`, + `issue_new.py`, `issue_ac.py` Invoke `Skill` with `tea:sync` or `tea:issue` at the start of the task, and use the command table it gives you verbatim. The skill is the single source of @@ -46,7 +47,11 @@ instead of trying it. the `tea-guard` hook substitutes the pinned login. Never name a login. 2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts write files; you do not. If a task needs a body edited or a metadata field - changed by hand, stop and say which file and which field. + changed by hand, stop and say which file and which field. `issue_ac.py` is + the one script that touches a body, and it changes a single character: tick + only the items the caller named, by the number or the substring the caller + gave. Whether a criterion is actually met is a judgement about content, and + content is never yours. 3. **Push only what you were told to push.** `push.py` publishes to a tracker other people read. Run it with the ids the caller named, or with the filter the caller named. Never widen the set, never run a bare `push.py` because it diff --git a/skills/issue/SKILL.md b/skills/issue/SKILL.md index 53452e4..82432d9 100644 --- a/skills/issue/SKILL.md +++ b/skills/issue/SKILL.md @@ -36,6 +36,7 @@ All offline, all in `/scripts/`. |---|---| | `issue_new.py --type T --title "…"` | create `tmp/issues/.md` from the type's template | | `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors | +| `issue_ac.py [--check N\|TEXT]` | list the body's checkboxes; tick or untick one | | `issue_tree.py [id…]` | draw the dependency graph from `depends:` | | `issue_index.py` | rebuild `tmp/issues/INDEX.md` | | `issue.py` | the domain module the others import — not a command | @@ -92,13 +93,48 @@ decision — `/tea:sync` — and does not change the file's status here. ## Editing an issue -Edit the file. Change `state:` to close it, edit `labels:`, tick checkboxes in -`## Acceptance criteria`, add ids to `depends:`. Re-run `issue_check.py` -afterwards, and `issue_index.py` to refresh the table. +Edit the file. Change `state:` to close it, edit `labels:`, add ids to +`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh +the table. Checkboxes are the exception — use `issue_ac.py`, below. If the issue is synced (`origin: gitea`), your edit is local until you run `push.py --update` from `/tea:sync`. Nothing tracks that drift automatically. +## Ticking checkboxes + +A checkbox is the one part of a body that is **state** and not prose, so it has +a command of its own. Never rewrite a body just to tick a box: the rewrite +re-flows lines and re-words sentences, and the issue's diff swells around a +change that means one character. + +```bash +python3 /scripts/issue_ac.py wire-sqlc-appclick +python3 /scripts/issue_ac.py wire-sqlc-appclick --check 3 +python3 /scripts/issue_ac.py wire-sqlc-appclick --check "регресс" +python3 /scripts/issue_ac.py wire-sqlc-appclick --uncheck 3 +``` + +With no flag it prints the numbered list with each item's state, grouped by the +heading the item sits under. `--check` / `--uncheck` take that number or a +substring of the item's text (case-insensitive). + +- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A + `type/feature` keeps its children as checkboxes under `## Issues`, and they + are numbered in the same list. The script is named after the section most + boxes live in, nothing more. +- **A substring must match exactly one item.** Two matches is an error that + lists them; pick by number instead. It never guesses. +- **Exactly one character of the file changes.** Metadata, wording, wrapping + and trailing whitespace all come back byte for byte, so `git diff` and the + tracker's diff show the tick and nothing else. +- Examples inside a ``` fence are markup, not state — they are skipped. +- `INDEX.md` gains a `progress` column (`3/7`, blank when the issue has no + boxes), recomputed from the body on every build and stored in no field. + `issue_ac.py` rebuilds the index after a successful tick. + +Getting the tick to the tracker is a separate step — `push.py --update` in +`/tea:sync`. + ## Writing a proper description Issues get filed on the run — "comments aren't pulled", "the guard broke". diff --git a/skills/issue/references/format.md b/skills/issue/references/format.md index d41d063..f2114bd 100644 --- a/skills/issue/references/format.md +++ b/skills/issue/references/format.md @@ -162,6 +162,13 @@ grep -ln 'depends:.*migrate-schema' tmp/issues/*.md valid answer. - Acceptance criteria are `- [ ]` checkboxes; each item is an objectively checkable condition, not an aspiration. +- A checkbox is **item markup, not a property of one section**: `- [ ]` + unticked, `- [x]` ticked, and it means the same under `## Issues` as under + `## Acceptance criteria`. An item that wraps continues on an indented line + and is still one item. A `- [ ]` inside a ``` code fence is an example of the + markup, not state. Tick them with `issue_ac.py`, which reads the whole body + on exactly these rules and rewrites one character; progress (`3/7`) is + counted off the body and is never a metadata field. - Code references use the `path/file.ext:line` form; related issues by id. - Screenshots are allowed but their content must be duplicated as text — an LLM reading these files cannot see images. diff --git a/skills/issue/scripts/issue.py b/skills/issue/scripts/issue.py index 293dba9..d75d210 100644 --- a/skills/issue/scripts/issue.py +++ b/skills/issue/scripts/issue.py @@ -45,6 +45,7 @@ without a parser: grep -l 'labels:.*type/bug' tmp/issues/*.md grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it """ +import collections import os import re @@ -285,6 +286,127 @@ def body_dep_refs(body): return out +# -------------------------------------------------------------------------- +# checkboxes +# -------------------------------------------------------------------------- + +# A checkbox is the one part of a body that is *state* and not prose, so the +# format gives it markup of its own (references/format.md:163-164). It is item +# markup, not a property of one section: `## Acceptance criteria` is the usual +# home, but a type/feature keeps its children as checkboxes under `## Issues` +# (format.md:275-277). The scan is therefore over the whole text and the +# heading is only recorded, never required. +CHECKBOX_RE = re.compile( + r'^(?P[ \t]*)(?P[-*+]|\d+[.)])[ \t]+' + r'\[(?P[ xX])\](?=[ \t]|$)(?P.*)$') +# Any list item — a sibling ends the item above it, checkbox or not. +LIST_ITEM_RE = re.compile(r'^[ \t]*([-*+]|\d+[.)])([ \t]|$)') +FENCE_RE = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})') + +Checkbox = collections.namedtuple( + "Checkbox", "index line end_line checked text section") + + +def checkboxes(text): + """Every checkbox item in `text`, in document order. + + A pure function of the string it is given — no I/O, no store, no tracker. + Pass an issue body (`Issue.body`) to get body-relative line numbers, or a + whole file to get file-relative ones; nothing else changes. + + Returns a list of `Checkbox` namedtuples: + + index 1-based position in this list — what a user types to pick it + line 1-based line of the `- [ ]` marker, in the text given + end_line 1-based last line of the item, continuation lines included + checked True for `[x]` / `[X]`, False for `[ ]` + text the item's text; continuation lines joined with one space + section nearest preceding `## ` heading, "" above the first one + + Rules: + + - Only a line matching CHECKBOX_RE opens an item. A wrapped ("continuation") + line is part of the item above it, never an item of its own; the item + runs to the next blank line, heading, code fence, or list marker. + - Fenced code blocks are skipped whole: `- [ ]` inside a ``` fence is an + example of the markup, not a box anybody may tick. + - `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested + lists are seen too. + """ + lines = (text or "").splitlines() + items, section, fence = [], "", "" + for n, line in enumerate(lines, 1): + m = FENCE_RE.match(line) + if m: + tok = m.group(1) + if not fence: + fence = tok + elif tok[0] == fence[0] and len(tok) >= len(fence): + fence = "" + continue + if fence: + continue + if line.startswith("## "): + section = line.strip() + continue + if line.startswith("# "): + section = "" + continue + m = CHECKBOX_RE.match(line) + if not m: + continue + end, parts = n, [m.group("text").strip()] + for k in range(n, len(lines)): # lines[k] is line number k + 1 + nxt = lines[k] + if (not nxt.strip() or nxt.startswith("#") + or FENCE_RE.match(nxt) or LIST_ITEM_RE.match(nxt)): + break + end = k + 1 + parts.append(nxt.strip()) + items.append(Checkbox(len(items) + 1, n, end, + m.group("box") != " ", + " ".join(p for p in parts if p), section)) + return items + + +def set_checkbox(text, item, checked=True): + """Return `text` with one checkbox set to `checked`. + + Pure, and deliberately surgical: exactly one character of the input + changes — the one between the brackets. Everything else, including + trailing whitespace and the item's own wording, comes back byte for byte. + That is the whole point of the function: ticking a box must not produce a + diff wider than the state that changed. + + `item` is a `Checkbox` from `checkboxes(text)` — the same text, or the + line number will point at the wrong line — or a 1-based line number. + Already in the requested state is a no-op: `text` is returned unchanged, + and an existing `[X]` keeps its capital. + """ + line_no = item.line if isinstance(item, Checkbox) else int(item) + off = 0 + for n, raw in enumerate(text.splitlines(True), 1): + if n == line_no: + m = CHECKBOX_RE.match(raw.rstrip("\r\n")) + if not m: + raise ValueError("line %d is not a checkbox item" % line_no) + if (m.group("box") != " ") == bool(checked): + return text + box = off + m.start("box") + return text[:box] + ("x" if checked else " ") + text[box + 1:] + off += len(raw) + raise ValueError("line %d is past the end of the text" % line_no) + + +def checkbox_progress(text): + """(done, total) over every checkbox in `text`; (0, 0) when it has none. + + Computed on the fly, on purpose. Progress is not a metadata field: it is + the body read back, and the body is the only place the state lives.""" + items = checkboxes(text) + return sum(1 for c in items if c.checked), len(items) + + # -------------------------------------------------------------------------- # validation # -------------------------------------------------------------------------- @@ -349,6 +471,10 @@ def validate(issue, known_ids=None): warn.append("%s mentions %r but `depends:` does not list it" % (DEPENDS_SECTION, ref)) + # An unticked checkbox is never a finding — neither an error nor a + # warning. `- [ ]` is work not done yet, which is the normal state of a + # perfectly well-formed issue. Reading that state is issue_ac.py's job. + return err, warn diff --git a/skills/issue/scripts/issue_ac.py b/skills/issue/scripts/issue_ac.py new file mode 100644 index 0000000..1967e3b --- /dev/null +++ b/skills/issue/scripts/issue_ac.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +issue_ac.py — list and tick the checkboxes in an issue's body. Offline. + + issue_ac.py wire-sqlc-appclick numbered list with state + issue_ac.py wire-sqlc-appclick --check 3 by number + issue_ac.py wire-sqlc-appclick --check регресс by substring + issue_ac.py wire-sqlc-appclick --uncheck 3 + +A checkbox is the one part of a body that is *state* and not prose. Everything +else is written once; boxes get ticked as the work goes, and until now the only +ways to tick one were a human with an editor or a model rewriting the whole +body — the second worse than the first, because the rewrite re-flows the text +and the issue's diff swells around a change of one character. This changes that +one character and nothing else. + +Named after `## Acceptance criteria`, where most boxes live, but every checkbox +in the body is listed and tickable: a type/feature keeps its children under +`## Issues`, and binding this to one heading would silently lose half of them. + +A substring picks an item only when it picks exactly one. Two matches is an +error listing both — a coin flip would tick the wrong box and look like it +worked. + +Delivering the changed body to a tracker is not part of this: that is +`push.py --update` in /tea:sync. +""" +import argparse +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import issue # noqa: E402 +import issue_index # noqa: E402 + +NUMBER = re.compile(r'^\d+$') + + +def box(c): + return "[x]" if c.checked else "[ ]" + + +def listing(items): + """The numbered list, grouped by the heading each item sits under.""" + out, section = [], None + for c in items: + if c.section != section: + section = c.section + out.append("") + out.append(section or "(above the first heading)") + out.append(" %2d %s %s" % (c.index, box(c), c.text)) + return out + + +def select(items, needle): + """Resolve a --check/--uncheck argument to exactly one item, or exit.""" + needle = (needle or "").strip() + if not needle: + sys.exit("issue_ac.py: empty selector — give an item number or a substring") + if NUMBER.match(needle): + n = int(needle) + if not 1 <= n <= len(items): + sys.exit("issue_ac.py: no item %d — the issue has %d" % (n, len(items))) + return items[n - 1] + hits = [c for c in items if needle.lower() in c.text.lower()] + if not hits: + sys.exit("issue_ac.py: nothing matches %r" % needle) + if len(hits) > 1: + sys.exit("\n".join( + ["issue_ac.py: %r matches %d items — narrow it down, or use a number:" + % (needle, len(hits))] + + [" %2d %s %s" % (c.index, box(c), c.text) for c in hits])) + return hits[0] + + +def main(argv=None): + ap = argparse.ArgumentParser( + description="List and tick an issue's checkboxes (offline)") + ap.add_argument("id", help="issue id (the slug, without .md)") + g = ap.add_mutually_exclusive_group() + g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring") + g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args(argv) + + path = issue.path_of(args.out, args.id) + if not os.path.exists(path): + sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out)) + # newline="": no translation in either direction. Byte-for-byte means the + # line endings too — reading a CRLF file in text mode and writing it back + # would rewrite every line while claiming to have changed one character. + with open(path, newline="") as f: + text = f.read() + + # The whole file, not just the body: line numbers then point at the file, + # and the metadata block is rewritten by nobody. Round-tripping through + # Issue.to_text() would re-render metadata and re-strip the body, which is + # exactly the byte-level churn this script exists to avoid. + items = issue.checkboxes(text) + needle = args.check if args.check is not None else args.uncheck + + if not items: + if needle is not None: + sys.exit("issue_ac.py: %s has no checkboxes" % args.id) + print("%s — no checkboxes" % args.id) + return 0 + + if needle is None: + done = sum(1 for c in items if c.checked) + print("%s — %d/%d %s" % (args.id, done, len(items), path)) + print("\n".join(listing(items))) + return 0 + + checked = args.check is not None + item = select(items, needle) + new = issue.set_checkbox(text, item, checked) + verb = "checked" if checked else "unchecked" + if new == text: + print("unchanged %2d %s %s" % (item.index, box(item), item.text)) + return 0 + + with open(path, "w", newline="") as f: + f.write(new) + issue_index.build(args.out) + + done, total = issue.checkbox_progress(new) + print("%s %2d %s %s" % (verb, item.index, "[x]" if checked else "[ ]", item.text)) + print("%s — %d/%d %s:%d" % (args.id, done, total, path, item.line)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/issue/scripts/issue_index.py b/skills/issue/scripts/issue_index.py index 6a21369..bd09a10 100644 --- a/skills/issue/scripts/issue_index.py +++ b/skills/issue/scripts/issue_index.py @@ -26,6 +26,16 @@ def cell(v): return v.replace("|", "\\|") or "—" +def progress(body): + """`3/7` for a body with checkboxes, "" for one without. + + Counted from the body every time the index is built and stored nowhere — + the boxes are the state, and a second copy of it in a metadata field would + be wrong by the next edit.""" + done, total = issue.checkbox_progress(body) + return "%d/%d" % (done, total) if total else "" + + def build(root): issues = issue.load_all(root) rows = [] @@ -35,6 +45,7 @@ def build(root): rows.append({ "id": i, "state": cell(iss.state), + "progress": progress(iss.body), "type": cell(iss.type), "labels": cell(rest), "title": cell(iss.title), @@ -50,13 +61,16 @@ def build(root): "Every issue this project knows about. `origin: local` means it " "exists nowhere else — a complete state, not a pending one. Any " "other value names the tracker it also lives in; the handle is in " - "the file. Rebuild with `issue_index.py`.", ""] + "the file. `progress` counts the body's checkboxes, ticked over " + "total, and is blank for an issue that has none — read off the " + "body at build time, stored nowhere. Rebuild with `issue_index.py`; " + "tick a box with `issue_ac.py`.", ""] if rows: - out += ["| id | state | type | labels | title | milestone | depends | origin |", - "|---|---|---|---|---|---|---|---|"] - out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s |" % ( - r["id"], r["id"], r["state"], r["type"], r["labels"], r["title"], - r["milestone"], r["depends"], r["origin"]) for r in rows] + out += ["| id | state | progress | type | labels | title | milestone | depends | origin |", + "|---|---|---|---|---|---|---|---|---|"] + out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % ( + r["id"], r["id"], r["state"], r["progress"], r["type"], r["labels"], + r["title"], r["milestone"], r["depends"], r["origin"]) for r in rows] else: out.append("_empty_") diff --git a/tests/test_checkboxes.py b/tests/test_checkboxes.py new file mode 100644 index 0000000..351a09e --- /dev/null +++ b/tests/test_checkboxes.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +""" +Checkbox parsing, ticking, and the INDEX progress column. + +Plain stdlib unittest — the scripts under test are stdlib-only by the layering +rule, and their tests have no business dragging in a dependency the code they +cover is forbidden to have. `skills/*/scripts/` are directories of scripts, not +packages, so they go on sys.path the same way the scripts do it to each other. + + python3 -m unittest discover -s tests -v + +Nothing here touches tmp/, the network, or the real store: every case builds +its own store in a TemporaryDirectory. +""" +import contextlib +import io +import os +import sys +import tempfile +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "skills", "issue", "scripts")) + +import issue # noqa: E402 +import issue_ac # noqa: E402 +import issue_check # noqa: E402 +import issue_index # noqa: E402 + +# Boxes in two different sections, a wrapped item, a fenced example, and a +# plain list item that is not a checkbox at all. Line numbers are 1-based: +# the items sit on lines 8, 9, 12, 14 and 15. +BODY = """## Summary +Что-то про задачу. + +## Spec +none + +## Issues +- [x] wire-sqlc-appclick — первая часть +- [ ] add-pool-cfg — вторая часть + +## Acceptance criteria +- [ ] в `issue.py` есть функция разбора чекбоксов тела: + возвращает пункты с номером строки, состоянием и текстом +- [X] чекбоксы ищутся по всему телу +- [ ] пример в блоке кода не считается пунктом: + +```markdown +- [ ] это разметка из шаблона, а не галочка +- [x] и эта тоже +``` + +## Constraints +- не входит в объём: доставка тела в трекер +""" + +NO_BOXES = """## Summary +Тело без единой галочки. + +## Spec +none + +## Notes +- обычный пункт списка +- ещё один +""" + +# Metadata deliberately out of canonical order and missing optional keys, one +# item with trailing whitespace: a round-trip through Issue.to_text() would +# rewrite all of that, so this fixture catches a ticking path that re-renders +# the file instead of patching one character of it. +MESSY = """--- +origin: local +labels: [type/task] +id: messy-issue +state: open +--- +# Messy but valid + +## Summary +Тело, которое нельзя перерисовывать. + +## Spec +none + +## Acceptance criteria +- [ ] первый пункт +- [ ] второй пункт +- [ ] третий пункт +""" + +TASK_BODY = """## Summary +Что нужно сделать. + +## Spec +none + +## Motivation +Зачем это нужно. + +## Acceptance criteria +- [ ] ничего ещё не сделано +- [ ] и это тоже не сделано +""" + + +def sole_difference(before, after): + """The single character position at which the two strings differ. + + Raises AssertionError when they differ in length or in more than one + place — the whole claim of `set_checkbox` is that this never happens.""" + assert len(before) == len(after), ( + "length changed: %d -> %d" % (len(before), len(after))) + diff = [i for i, (a, b) in enumerate(zip(before, after)) if a != b] + assert len(diff) == 1, "expected 1 differing character, got %d" % len(diff) + return diff[0] + + +@contextlib.contextmanager +def store(**files): + """A throwaway issue store: {id: file text}.""" + with tempfile.TemporaryDirectory() as root: + for id, text in files.items(): + with open(os.path.join(root, "%s.md" % id), "w", newline="") as f: + f.write(text) + yield root + + +def run(fn, *argv): + """Call a script entry point, returning (exit code or None, stdout).""" + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = fn(list(argv)) + return rc, buf.getvalue() + + +class TestParse(unittest.TestCase): + + def test_finds_every_box_in_every_section(self): + items = issue.checkboxes(BODY) + self.assertEqual([c.index for c in items], [1, 2, 3, 4, 5]) + self.assertEqual([c.line for c in items], [8, 9, 12, 14, 15]) + self.assertEqual([c.checked for c in items], + [True, False, False, True, False]) + self.assertEqual([c.section for c in items], + ["## Issues", "## Issues"] + ["## Acceptance criteria"] * 3) + + def test_boxes_outside_acceptance_criteria_are_items_too(self): + # A type/feature keeps its children under `## Issues`; binding the + # parser to one heading would lose them. + under_issues = [c for c in issue.checkboxes(BODY) if c.section == "## Issues"] + self.assertEqual(len(under_issues), 2) + self.assertTrue(under_issues[0].text.startswith("wire-sqlc-appclick")) + + def test_continuation_line_is_part_of_the_item(self): + item = issue.checkboxes(BODY)[2] + self.assertEqual((item.line, item.end_line), (12, 13)) + self.assertEqual( + item.text, + "в `issue.py` есть функция разбора чекбоксов тела: " + "возвращает пункты с номером строки, состоянием и текстом") + + def test_fenced_example_is_not_an_item(self): + texts = [c.text for c in issue.checkboxes(BODY)] + self.assertNotIn("это разметка из шаблона, а не галочка", texts) + self.assertEqual(len(texts), 5) + + def test_plain_list_item_is_not_a_checkbox(self): + self.assertNotIn("не входит в объём: доставка тела в трекер", + [c.text for c in issue.checkboxes(BODY)]) + + def test_markers_and_nesting(self): + text = ("* [ ] star\n" + "+ [x] plus\n" + "1. [ ] ordered\n" + "2) [x] ordered too\n" + " - [ ] nested\n" + "- [x]no space, not an item\n") + items = issue.checkboxes(text) + self.assertEqual([c.text for c in items], + ["star", "plus", "ordered", "ordered too", "nested"]) + self.assertEqual([c.checked for c in items], + [False, True, False, True, False]) + + def test_empty_text(self): + self.assertEqual(issue.checkboxes(""), []) + self.assertEqual(issue.checkboxes(None), []) + + def test_line_numbers_are_relative_to_the_text_given(self): + # Same body, prefixed with a metadata block: the offsets move with it, + # which is what lets issue_ac.py work on a whole file. + head = "---\nid: x\nstate: open\n---\n# Title\n\n" + shift = head.count("\n") + self.assertEqual([c.line for c in issue.checkboxes(head + BODY)], + [c.line + shift for c in issue.checkboxes(BODY)]) + + def test_progress(self): + self.assertEqual(issue.checkbox_progress(BODY), (2, 5)) + self.assertEqual(issue.checkbox_progress(NO_BOXES), (0, 0)) + + +class TestToggle(unittest.TestCase): + + def test_ticking_changes_exactly_one_character(self): + item = issue.checkboxes(BODY)[1] # line 9, unchecked + after = issue.set_checkbox(BODY, item, True) + at = sole_difference(BODY, after) + self.assertEqual(BODY[at], " ") + self.assertEqual(after[at], "x") + self.assertEqual(issue.checkbox_progress(after), (3, 5)) + + def test_unticking_changes_exactly_one_character(self): + item = issue.checkboxes(BODY)[0] # line 8, checked + after = issue.set_checkbox(BODY, item, False) + at = sole_difference(BODY, after) + self.assertEqual((BODY[at], after[at]), ("x", " ")) + + def test_every_item_toggles_in_isolation(self): + for item in issue.checkboxes(BODY): + after = issue.set_checkbox(BODY, item, not item.checked) + at = sole_difference(BODY, after) + self.assertEqual(after.splitlines()[item.line - 1].count("["), 1) + self.assertLess(at, len(BODY)) + + def test_no_op_when_already_in_that_state(self): + items = issue.checkboxes(BODY) + self.assertIs(issue.set_checkbox(BODY, items[0], True), BODY) + self.assertIs(issue.set_checkbox(BODY, items[1], False), BODY) + + def test_capital_x_is_left_alone(self): + item = issue.checkboxes(BODY)[3] # `- [X]` + self.assertEqual(issue.set_checkbox(BODY, item, True), BODY) + + def test_accepts_a_line_number(self): + after = issue.set_checkbox(BODY, 9, True) + self.assertEqual(after, issue.set_checkbox(BODY, issue.checkboxes(BODY)[1], True)) + + def test_refuses_a_line_that_is_not_a_checkbox(self): + with self.assertRaises(ValueError): + issue.set_checkbox(BODY, 1, True) + with self.assertRaises(ValueError): + issue.set_checkbox(BODY, 9999, True) + + +class TestScript(unittest.TestCase): + + def test_lists_items_numbered_with_state(self): + with store(**{"messy-issue": MESSY}) as root: + rc, out = run(issue_ac.main, "messy-issue", "--out", root) + self.assertEqual(rc, 0) + self.assertIn("messy-issue — 0/3", out) + self.assertIn("## Acceptance criteria", out) + self.assertIn(" 1 [ ] первый пункт", out) + self.assertIn(" 3 [ ] третий пункт", out) + + def test_check_by_number(self): + with store(**{"messy-issue": MESSY}) as root: + rc, out = run(issue_ac.main, "messy-issue", "--check", "2", "--out", root) + with open(os.path.join(root, "messy-issue.md")) as f: + after = f.read() + self.assertEqual(rc, 0) + self.assertIn("checked", out) + self.assertIn("1/3", out) + self.assertEqual(issue.checkbox_progress(after), (1, 3)) + + def test_check_by_substring(self): + with store(**{"messy-issue": MESSY}) as root: + run(issue_ac.main, "messy-issue", "--check", "ТРЕТИЙ", "--out", root) + with open(os.path.join(root, "messy-issue.md")) as f: + after = f.read() + self.assertTrue(issue.checkboxes(after)[2].checked) + self.assertEqual(issue.checkbox_progress(after), (1, 3)) + + def test_uncheck(self): + with store(**{"messy-issue": MESSY}) as root: + run(issue_ac.main, "messy-issue", "--check", "1", "--out", root) + rc, out = run(issue_ac.main, "messy-issue", "--uncheck", "1", "--out", root) + with open(os.path.join(root, "messy-issue.md")) as f: + after = f.read() + self.assertEqual(rc, 0) + self.assertIn("unchecked", out) + self.assertEqual(after, MESSY) + + def test_toggling_through_the_script_changes_one_character_of_the_file(self): + with store(**{"messy-issue": MESSY}) as root: + path = os.path.join(root, "messy-issue.md") + with open(path) as f: + before = f.read() + run(issue_ac.main, "messy-issue", "--check", "второй", "--out", root) + with open(path) as f: + after = f.read() + at = sole_difference(before, after) + self.assertEqual((before[at], after[at]), (" ", "x")) + # The metadata block was neither reordered nor completed, and the + # trailing whitespace on the third item survived. + self.assertTrue(after.startswith("---\norigin: local\n")) + self.assertIn("- [ ] третий пункт \n", after) + + def test_crlf_line_endings_survive(self): + crlf = MESSY.replace("\n", "\r\n") + with store(**{"messy-issue": crlf}) as root: + path = os.path.join(root, "messy-issue.md") + run(issue_ac.main, "messy-issue", "--check", "1", "--out", root) + with open(path, newline="") as f: + after = f.read() + at = sole_difference(crlf, after) + self.assertEqual((crlf[at], after[at]), (" ", "x")) + self.assertEqual(after.count("\r\n"), crlf.count("\r\n")) + + def test_ambiguous_substring_is_an_error_listing_the_matches(self): + with store(**{"messy-issue": MESSY}) as root: + with self.assertRaises(SystemExit) as cm: + run(issue_ac.main, "messy-issue", "--check", "пункт", "--out", root) + with open(os.path.join(root, "messy-issue.md")) as f: + self.assertEqual(f.read(), MESSY) # nothing was picked + msg = str(cm.exception) + self.assertIn("matches 3 items", msg) + for want in ("1 [ ] первый пункт", "2 [ ] второй пункт", "3 [ ] третий пункт"): + self.assertIn(want, msg) + + def test_substring_that_matches_nothing(self): + with store(**{"messy-issue": MESSY}) as root: + with self.assertRaises(SystemExit) as cm: + run(issue_ac.main, "messy-issue", "--check", "нетакого", "--out", root) + self.assertIn("nothing matches", str(cm.exception)) + + def test_number_out_of_range(self): + with store(**{"messy-issue": MESSY}) as root: + with self.assertRaises(SystemExit) as cm: + run(issue_ac.main, "messy-issue", "--check", "9", "--out", root) + self.assertIn("no item 9 — the issue has 3", str(cm.exception)) + + def test_issue_without_checkboxes(self): + with store(**{"plain": "---\nid: plain\n---\n# Plain\n\n" + NO_BOXES}) as root: + rc, out = run(issue_ac.main, "plain", "--out", root) + self.assertEqual((rc, out.strip()), (0, "plain — no checkboxes")) + with self.assertRaises(SystemExit) as cm: + run(issue_ac.main, "plain", "--check", "1", "--out", root) + self.assertIn("has no checkboxes", str(cm.exception)) + + def test_unknown_id(self): + with store() as root: + with self.assertRaises(SystemExit) as cm: + run(issue_ac.main, "nope", "--out", root) + self.assertIn("no issue 'nope'", str(cm.exception)) + + +class TestIndexProgress(unittest.TestCase): + + def files(self): + boxed = ("---\nid: boxed\nstate: open\nlabels: [type/task]\n" + "origin: local\n---\n# Boxed\n\n" + BODY) + plain = ("---\nid: plain\nstate: open\nlabels: [type/task]\n" + "origin: local\n---\n# Plain\n\n" + NO_BOXES) + return {"boxed": boxed, "plain": plain} + + def index(self, root): + issue_index.build(root) + with open(os.path.join(root, "INDEX.md")) as f: + return f.read() + + def row(self, text, id): + for line in text.splitlines(): + if line.startswith("| [%s]" % id): + return [c.strip() for c in line.split("|")] + self.fail("no row for %r in INDEX.md" % id) + + def test_column_exists_and_counts_the_body(self): + with store(**self.files()) as root: + text = self.index(root) + self.assertIn("| id | state | progress | type |", text) + self.assertEqual(self.row(text, "boxed")[3], "2/5") + + def test_blank_for_an_issue_without_checkboxes(self): + with store(**self.files()) as root: + text = self.index(root) + self.assertEqual(self.row(text, "plain")[3], "") + + def test_recomputed_on_the_fly_not_stored(self): + with store(**self.files()) as root: + self.assertEqual(self.row(self.index(root), "boxed")[3], "2/5") + run(issue_ac.main, "boxed", "--check", "add-pool-cfg", "--out", root) + self.assertEqual(self.row(self.index(root), "boxed")[3], "3/5") + # No metadata field anywhere holds it. + with open(os.path.join(root, "boxed.md")) as f: + head = f.read().split("---")[1] + self.assertNotIn("3/5", head) + self.assertNotIn("progress", head) + + +class TestCheckIgnoresUntickedBoxes(unittest.TestCase): + """An unticked box is work not done yet, not a malformed issue.""" + + def test_validate_reports_nothing(self): + iss = issue.Issue(id="unticked-issue", title="Do the thing", + labels=["type/task"], body=TASK_BODY) + err, warn = issue.validate(iss, known_ids={"unticked-issue"}) + self.assertEqual(err, []) + self.assertEqual(warn, []) + + def test_issue_check_exits_clean(self): + text = ("---\nid: unticked-issue\nstate: open\nlabels: [type/task]\n" + "assignees: []\nmilestone: none\ndepends: []\norigin: local\n" + "---\n# Do the thing\n\n" + TASK_BODY) + argv = sys.argv + with store(**{"unticked-issue": text}) as root: + sys.argv = ["issue_check.py", "--out", root] + try: + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = issue_check.main() + finally: + sys.argv = argv + out = buf.getvalue() + self.assertEqual(rc, 0, out) + self.assertIn("ok unticked-issue", out) + self.assertNotIn("ERROR", out) + self.assertNotIn("warn ", out) + + +if __name__ == "__main__": + unittest.main()