diff --git a/AGENTS.md b/AGENTS.md index 3b7c892..81909fb 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`) 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 05f0e83..f8c5716 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 | @@ -110,13 +111,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 2a6fd93..ab7b1e2 100644 --- a/skills/issue/references/format.md +++ b/skills/issue/references/format.md @@ -167,6 +167,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 121da7c..cfab685 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 @@ -365,6 +366,127 @@ def body_dep_refs(body): return [ref for _, ref in body_dep_ref_sections(body)] +# -------------------------------------------------------------------------- +# 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 # -------------------------------------------------------------------------- @@ -430,6 +552,10 @@ def validate(issue, known_ids=None): warn.append("%s mentions %r but `depends:` does not list it" % (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 afc617a..57de6d5 100644 --- a/skills/issue/scripts/issue_index.py +++ b/skills/issue/scripts/issue_index.py @@ -30,6 +30,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): # An index of a store that is not there is not an empty index, it is a bad # path. Raising beats writing INDEX.md into a directory nobody asked for. @@ -42,6 +52,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), @@ -57,13 +68,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()