fb5445915f
`issue.store_root` and `_gitea.PAYLOAD_ROOT` were anchored on `__file__`, on
the reasoning that where an installation keeps its files is a fact about the
installation. That holds for an installation and not for a store.
Installed, the plugin therefore resolved every project's issues inside its own
directory — and a plugin cache is versioned, so the store moved on each
update:
~/.claude/plugins/cache/tea/tea/2.0.0/tmp/issues 5 files, 2 origin: local
~/.claude/plugins/cache/tea/tea/2.1.0/tmp/issues 12 files
~/.claude/plugins/cache/claude-skills/tea/2.2.0/ empty, the current one
Issues written from one project were invisible from the next, and an `origin:
local` file — which IS the issue, the only copy — was stranded a version bump
at a time. Two of them were.
The store is a fact about the project, exactly as the login pin is. So the
anchor is now an explicit marker an operator creates, `.tea/`, searched for up
from $CLAUDE_PROJECT_DIR and then cwd — the pin's order, so the two cannot
disagree about which project this is. Inferred markers were tried and are worse
than useless here: `.git` is in every clone including this plugin's own, and
the agents-sync hook writes an AGENTS.md next to every AGENTS.md, so the plugin
root always carried one and cwd never got a turn.
With no marker anywhere, `store_root()` is None and every entry point reports
which directories it searched. A store in a plausible-looking directory is the
failure this replaces, so nothing falls back to one.
- `.tea/` holds the store and the transport's scratchpad: `.tea/issues`,
`.tea/payload`. One marker, one walk, one gitignore line.
- `issue_init.py` creates it, moves an old `tmp/issues` store in rather than
copying, adds `.tea/` to `.gitignore`, and refuses to pick a winner when both
sides hold the same file name.
- A linked worktree has no marker — it is gitignored — and reaches the main
checkout's store by the hop the pin already took.
- `parents`, `gitdir_of` and `main_worktree` move from `pin.py` into the domain
and `pin.py` imports them. The domain depends on nothing, so it is the layer
all three callers can borrow from, and the walk stays written once: the
guard, the transport and the store cannot disagree about a directory.
The suite stopped copying the script layers into its fixtures. That is what hid
this: with the scripts inside the fixture, the installation and the project
were the same directory. They are now deliberately far apart, and a regression
test asserts the plugin tree gains no files when commands run against a project
somewhere else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
138 lines
5.2 KiB
Python
138 lines
5.2 KiB
Python
#!/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: .tea/issues)")
|
|
args = ap.parse_args(argv)
|
|
|
|
if args.out is None:
|
|
sys.exit("issue_ac.py: %s" % issue.no_project_error())
|
|
|
|
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())
|