merge: tick in-body checkboxes from a domain script
# Conflicts: # AGENTS.md
This commit is contained in:
@@ -47,9 +47,11 @@ the domain layer, it is in the wrong place.
|
|||||||
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
||||||
- `references/format.md` — canonical issue format; single source of truth
|
- `references/format.md` — canonical issue format; single source of truth
|
||||||
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
- `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_new.py` — create a local issue from its type template
|
||||||
- `scripts/issue_check.py` — validate against the format
|
- `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_tree.py` — draw the dependency graph
|
||||||
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
||||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
||||||
|
|||||||
@@ -127,9 +127,11 @@ skills/
|
|||||||
references/format.md canonical issue format (identity, types, templates)
|
references/format.md canonical issue format (identity, types, templates)
|
||||||
scripts/ Python 3, stdlib only, no network:
|
scripts/ Python 3, stdlib only, no network:
|
||||||
issue.py domain module: slug identity, parse/render,
|
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_new.py create a local issue from its type template
|
||||||
issue_check.py validate against the format
|
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_tree.py draw the dependency graph
|
||||||
issue_index.py rebuild tmp/issues/INDEX.md
|
issue_index.py rebuild tmp/issues/INDEX.md
|
||||||
sync/ /tea:sync — the bridge to Gitea
|
sync/ /tea:sync — the bridge to Gitea
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ to fill the gap yourself.
|
|||||||
Load the skill, do not remember the flags:
|
Load the skill, do not remember the flags:
|
||||||
|
|
||||||
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `remote.py`, `labels.py`
|
- `/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
|
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
|
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.
|
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
|
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
|
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
|
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
|
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
|
the caller named. Never widen the set, never run a bare `push.py` because it
|
||||||
|
|||||||
+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_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_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_tree.py [id…]` | draw the dependency graph from `depends:` |
|
||||||
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
|
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
|
||||||
| `issue.py` | the domain module the others import — not a command |
|
| `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
|
## Editing an issue
|
||||||
|
|
||||||
Edit the file. Change `state:` to close it, edit `labels:`, tick checkboxes in
|
Edit the file. Change `state:` to close it, edit `labels:`, add ids to
|
||||||
`## Acceptance criteria`, add ids to `depends:`. Re-run `issue_check.py`
|
`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh
|
||||||
afterwards, and `issue_index.py` to refresh the table.
|
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
|
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.
|
`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
|
## Writing a proper description
|
||||||
|
|
||||||
Issues get filed on the run — "comments aren't pulled", "the guard broke".
|
Issues get filed on the run — "comments aren't pulled", "the guard broke".
|
||||||
|
|||||||
@@ -167,6 +167,13 @@ grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
|
|||||||
valid answer.
|
valid answer.
|
||||||
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
|
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
|
||||||
checkable condition, not an aspiration.
|
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.
|
- 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
|
- Screenshots are allowed but their content must be duplicated as text — an
|
||||||
LLM reading these files cannot see images.
|
LLM reading these files cannot see images.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ without a parser:
|
|||||||
grep -l 'labels:.*type/bug' tmp/issues/*.md
|
grep -l 'labels:.*type/bug' tmp/issues/*.md
|
||||||
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
|
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
|
||||||
"""
|
"""
|
||||||
|
import collections
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -365,6 +366,127 @@ def body_dep_refs(body):
|
|||||||
return [ref for _, ref in body_dep_ref_sections(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
|
# validation
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -430,6 +552,10 @@ def validate(issue, known_ids=None):
|
|||||||
warn.append("%s mentions %r but `depends:` does not list it"
|
warn.append("%s mentions %r but `depends:` does not list it"
|
||||||
% (section, ref))
|
% (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
|
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())
|
||||||
@@ -30,6 +30,16 @@ def cell(v):
|
|||||||
return v.replace("|", "\\|") or "—"
|
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):
|
def build(root):
|
||||||
# An index of a store that is not there is not an empty index, it is a bad
|
# 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.
|
# path. Raising beats writing INDEX.md into a directory nobody asked for.
|
||||||
@@ -42,6 +52,7 @@ def build(root):
|
|||||||
rows.append({
|
rows.append({
|
||||||
"id": i,
|
"id": i,
|
||||||
"state": cell(iss.state),
|
"state": cell(iss.state),
|
||||||
|
"progress": progress(iss.body),
|
||||||
"type": cell(iss.type),
|
"type": cell(iss.type),
|
||||||
"labels": cell(rest),
|
"labels": cell(rest),
|
||||||
"title": cell(iss.title),
|
"title": cell(iss.title),
|
||||||
@@ -57,13 +68,16 @@ def build(root):
|
|||||||
"Every issue this project knows about. `origin: local` means it "
|
"Every issue this project knows about. `origin: local` means it "
|
||||||
"exists nowhere else — a complete state, not a pending one. Any "
|
"exists nowhere else — a complete state, not a pending one. Any "
|
||||||
"other value names the tracker it also lives in; the handle is in "
|
"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:
|
if rows:
|
||||||
out += ["| id | state | type | labels | title | milestone | depends | origin |",
|
out += ["| id | state | progress | type | labels | title | milestone | depends | origin |",
|
||||||
"|---|---|---|---|---|---|---|---|"]
|
"|---|---|---|---|---|---|---|---|---|"]
|
||||||
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s |" % (
|
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % (
|
||||||
r["id"], r["id"], r["state"], r["type"], r["labels"], r["title"],
|
r["id"], r["id"], r["state"], r["progress"], r["type"], r["labels"],
|
||||||
r["milestone"], r["depends"], r["origin"]) for r in rows]
|
r["title"], r["milestone"], r["depends"], r["origin"]) for r in rows]
|
||||||
else:
|
else:
|
||||||
out.append("_empty_")
|
out.append("_empty_")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user