merge: tick in-body checkboxes from a domain script
# Conflicts: # AGENTS.md
This commit is contained in:
@@ -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<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
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user