#!/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())