feat: tick in-body checkboxes from a domain script
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) <noreply@anthropic.com>
This commit is contained in:
+39
-3
@@ -36,6 +36,7 @@ All offline, all in `<skill-base-dir>/scripts/`.
|
||||
|---|---|
|
||||
| `issue_new.py --type T --title "…"` | create `tmp/issues/<slug>.md` from the type's template |
|
||||
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
|
||||
| `issue_ac.py <id> [--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 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check 3
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check "регресс"
|
||||
python3 <skill-base-dir>/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".
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+'
|
||||
r'\[(?P<box>[ xX])\](?=[ \t]|$)(?P<text>.*)$')
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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_")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user