feat: close issues through a script

Closing was the last regular tracker operation with no script behind it.
The only way to move state: was a raw `tea api -X PATCH` against
repos/OWNER/REPO/issues/N with a hand-written body, which spells out the
owner, the repo and the request shape — the three things _gitea.py exists
to hide — and which needs a Bash(tea api *) permission wide enough to
cover -X DELETE on the repository.

close.py takes explicit ids, one or many, as a local slug or as any key
form pull.py accepts (42, #42, owner/repo#42, a URL). A slug resolves
through its gitea: field while the file is there and through .remote.json
after push has dropped it, so an issue with no local copy is still
closeable by name. --reopen is the same run backwards.

State only: the payload carries state and nothing else. Closing is not an
edit; editing stays pull -> change -> push --update. No --milestone and
no --label either — which issues are finished is a judgement about
content, and this only carries one out, one named id at a time.

An origin: local issue is refused: it is not in the tracker, so there is
no state there to change, and the error names the id rather than quietly
editing one field of a local file. Every argument is resolved before
anything is sent, so a typo in the third id cannot leave the first two
closed, and one run addresses one repo — a key that names its own is sent
there instead of to whatever repo the CWD happens to be in.

The local file is written only after the tracker confirmed this write: an
object carrying the number that was PATCHed, in the state that was asked
for (close.confirmed). A non-2xx, a transport that would not run, an
answer for another issue, a 200 that still says open — the run stops and
the file is byte for byte what it was. --dry-run prints the same lines,
makes no request at all and needs no pinned login.

tea-runner rule 4 narrows accordingly: closing was forbidden because
nothing but a raw call could do it, not because it is dangerous. It may
now close the ids the caller named, and no others; deleting and retitling
stay forbidden.

tests/test_close.py stubs the transport at _gitea.api and, for the
non-2xx path, one layer lower at _gitea.subprocess so a CLI that exits 1
is proved end to end. 286 tests, no network, no tmp/issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-10 17:27:22 +05:00
parent 2ac301550e
commit 9679e2c000
6 changed files with 956 additions and 8 deletions
+50 -1
View File
@@ -1,6 +1,6 @@
---
name: sync
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, or comment on one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
---
# /tea:sync — the bridge between the local store and Gitea
@@ -41,6 +41,7 @@ the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty |
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
@@ -308,6 +309,54 @@ a git repo no `ref` is sent and a warning names the issues that went up without
one. Reading the branch is the only thing these scripts ask git for — they
never check out, create, or write anything.
## Closing and reopening
```bash
python3 <skill-base-dir>/scripts/close.py wire-sqlc-appclick # by slug
python3 <skill-base-dir>/scripts/close.py 42 '#43' # by number
python3 <skill-base-dir>/scripts/close.py --reopen 42
python3 <skill-base-dir>/scripts/close.py --dry-run 42 43 # no request at all
```
`close.py` is the only supported way to move `state:`. Never hand-roll
`tea api -X PATCH -d '{"state":"closed"}' repos/OWNER/REPO/issues/N`: it spells
out the owner, the repo and the request body — the three things this layer
exists to hide — and it needs a `Bash(tea api *)` permission that also covers
`-X DELETE` on the repository.
**State only.** The payload is `{"state": …}` and nothing else — no title, no
body, no labels, no milestone. Closing is not an edit; editing is `pull.py`
change → `push.py --update`.
**Explicit ids only.** There is no `--milestone` and no `--label`: which issues
are finished is a judgement about content, and this script only carries one
out, one named id at a time. Deleting an issue is out of scope too — Gitea can,
and it is not an operation of this workflow.
What may be named, and what happens to the local copy:
| named | resolved through | local file |
|---|---|---|
| a slug with a file on disk | its `gitea:` field | `state:` rewritten, `synced:` refreshed |
| a slug whose file push dropped | `.remote.json` | none to write — say so and move on |
| `42`, `#42`, `owner/repo#42`, a URL | the key itself; the ledger supplies the slug | rewritten when a file of that slug is there |
| a slug with `origin: local` | — | **refused**: it is not in the tracker, and the error names the id |
The local file is written only after the tracker has confirmed *this* write: an
object carrying the very number that was PATCHed, in the state that was asked
for. A non-2xx, a `tea` that would not run, an answer for another issue, a 200
that still says `open` — the run stops and the file is byte for byte what it
was. `--dry-run` prints the same lines and makes no request at all, so it needs
no pinned login.
Gitea refuses to close an issue that its own dependency graph still blocks. The
refusal arrives as a non-2xx with the tracker's own words: close the blockers
first, or unlink them in the web UI.
The index is rebuilt when at least one local file changed, so `INDEX.md` never
outlives the state it reports. Nothing is deleted here — unlike a push, a close
leaves the working copy where it is.
## What crosses the boundary, and what does not
| domain | Gitea | note |
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""
close.py — change an issue's state in Gitea, and in the local copy with it.
The one regular tracker operation that used to have no script: closing. Without
it the only way to move `state:` was a raw `tea api -X PATCH -d '{"state":
"closed"}' repos/OWNER/REPO/issues/N`, which spells out the owner, the repo and
the request body — the three things `_gitea.py` exists to hide — and which needs
`Bash(tea api *)`, a permission that also covers `-X DELETE` on the repository.
close.py wire-sqlc-appclick one issue, by slug
close.py wire-sqlc-appclick 42 #43 several, by slug or number
close.py --reopen 42 the same thing backwards
close.py --dry-run 42 43 what would happen, no request at all
STATE ONLY. This script sends `{"state": …}` and nothing else: no title, no
body, no labels, no milestone. Editing an issue is `pull.py` -> edit ->
`push.py --update`; closing it is not an edit.
**What may be named.** A local slug, or a Gitea key (`42`, `#42`,
`owner/repo#42`, an issue URL) — the same forms `pull.py` takes. Both are
needed, and for the same reason: a push deletes the local file, so most issues
in the tracker have no slug on disk to name them by. A slug is resolved through
the file's `gitea:` field when the file is there, and through the ledger
(`.remote.json`) when push has already dropped it.
**An `origin: local` issue cannot be closed.** It is not in the tracker, so
there is nothing to close there, and the run stops naming the id rather than
quietly editing one field of a local file. Delete it, or push it first.
**Explicit ids only.** No `--milestone`, no `--label`, no "close everything
that looks done". Which issues are finished is a judgement about content; this
script only carries it out, one named id at a time. Nothing here deletes an
issue either — Gitea can, and it is not an operation of this workflow.
The local file is written only after the tracker has confirmed the write:
1. `tea` ran and exited 0 (a non-2xx exits the run inside `_gitea.api`), and
2. the answer is an object carrying the very number that was PATCHed, and
3. its `state` is the state we asked for.
Anything else and the file is left exactly as it was — see `confirmed`. An
issue whose local copy is gone (pushed and dropped) is closed in Gitea and
nothing is written; the state comes down with the next `pull.py`.
Gitea refuses to close an issue that its own dependency graph still blocks. That
refusal arrives as a non-2xx and stops the run with the tracker's own words:
close the blockers first, or unlink them in the web UI.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import re
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import issue_index # noqa: E402
import map as gmap # noqa: E402
# What `_gitea.parse_key` accepts, asked as a question instead of an assertion:
# parse_key exits on anything it cannot read, and here "not a key" is the normal
# case — it means the argument is a slug. A slug never contains `#`, `/` or `:`,
# so the two vocabularies cannot collide.
KEY_RE = re.compile(r'^(#?\d+|[\w.-]+/[\w.-]+#\d+|https?://\S+)$')
def looks_like_key(arg):
return bool(KEY_RE.match((arg or "").strip()))
def ledger_pairs(remote_map, repo=None):
"""[(repo, number, slug)] from `.remote.json`, filtered to `repo`.
A `--repo` that was not given means "whatever the ledger holds": resolving
the repo's real name costs a request, and a dry run is required to make
none. The ambiguity that opens — one number under two repos — is caught at
lookup time rather than papered over."""
out = []
for key, slug in sorted(remote_map.items()):
r, n = gmap.parse_remote_key(key)
if n:
if repo is None or r == repo:
out.append((r, n, slug))
return out
def one(candidates, what, arg):
"""The single `(repo, value)` in `candidates`, None when empty, or exit.
Two answers mean the ledger knows this number (or this slug) under more than
one repository, and only `--repo` can settle that."""
got = sorted(set(candidates))
if len(got) > 1:
_gitea.die("%r matches %s under more than one repo (%s) — pass "
"--repo owner/repo" % (arg, what, ", ".join(r for r, _v in got)))
return got[0] if got else None
def resolve(arg, issues, pairs):
"""(id, number, repo) for one argument. Either of `id` and `repo` is None
when nothing this machine holds names it.
Order, and it is the order of what is most authoritative about this machine:
a file on disk, then the ledger, then nothing. A key skips straight to the
ledger — its number is already the tracker's answer, and the slug is only
wanted so the local copy, if there is one, can be kept honest.
`repo` travels out with the number because a key may name one
(`owner/repo#42`) and a `gitea:` field always does. Sending a foreign key to
whatever repo the CWD happens to be in would close somebody else's issue of
the same number, so the caller reconciles them before anything goes out."""
if looks_like_key(arg):
number, repo = _gitea.parse_key(arg)
hit = one([(r, s) for r, n, s in pairs
if n == number and (repo is None or r == repo)], "a slug", arg)
return (hit[1] if hit else None), number, repo or (hit[0] if hit else None)
iss = issues.get(arg)
if iss is not None:
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
if not number:
_gitea.die("%s is not in the tracker (origin: %s, no gitea: field) — "
"there is no state there to change; push.py %s first"
% (arg, iss.origin, arg))
return arg, number, repo
hit = one([(r, n) for r, n, s in pairs if s == arg], "a number", arg)
if hit:
return arg, hit[1], hit[0] # pushed, and its file went with the push
_gitea.die("no issue %r in the store or the ledger — pass a Gitea number "
"(42, #42, owner/repo#42, a URL) to close one this machine has "
"never seen" % arg)
def confirmed(got, number, state):
"""True when the tracker's answer confirms THIS write, and nothing else.
The gate in front of the local write, and deliberately boring: an answer
counts only when it is an object carrying the very number that was PATCHed
(`bool` rejected explicitly — `True` is an `int`) and the state that was
asked for. A non-2xx and a `tea` that would not run never reach here at all;
`_gitea.api` exits on both, so the file survives those by never being
written."""
if not isinstance(got, dict):
return False
n = got.get("number")
if isinstance(n, bool) or not isinstance(n, int) or n != number:
return False
return got.get("state") == state
def apply_state(root, iss, state, got):
"""Write the confirmed state onto the local file; return its path.
`state:` is the domain's own field, so it is set on the issue and written
out by the domain's own writer. The sync-owned freshness fields travel with
it: the answer that authorized this write is also the newest thing the
tracker has said about the issue, so `synced:` and `remote-updated:` are
stamped from it rather than left describing an older read."""
iss.state = state
iss.extra["synced"] = _gitea.now_iso()
if got.get("updated_at"):
iss.extra["remote-updated"] = got["updated_at"]
return issue.save(root, iss)
def main():
ap = argparse.ArgumentParser(description="Close (or reopen) issues in Gitea")
ap.add_argument("ids", nargs="+",
help="local ids, or Gitea keys: 42, #42, owner/repo#42, URL")
ap.add_argument("--reopen", action="store_true",
help="set the state back to open instead of closed")
ap.add_argument("--dry-run", action="store_true",
help="print what would change; makes no request at all")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
root = args.out
state = "open" if args.reopen else "closed"
verb = "reopen" if args.reopen else "close"
past = "reopened" if args.reopen else "closed"
# A store that is not there is not an error here: a number needs no local
# file, and closing an issue whose copy was dropped by push is the normal
# case. `load_all` reads an absent directory as an empty one.
issues = issue.load_all(root)
pairs = ledger_pairs(_gitea.load_map(root), args.repo)
# Every argument is resolved before anything is sent, so a typo in the third
# id does not leave the first two closed.
targets = []
for arg in args.ids:
got = resolve(arg, issues, pairs)
if got not in targets:
targets.append(got)
# One run, one repo. An explicit --repo is the operator's word and wins;
# without one, the repo comes from what the ids themselves said, and two
# answers are a question rather than a guess — `repo_base` would otherwise
# let `tea` fill the blank from the CWD and close the wrong #42.
named = {r for _i, _n, r in targets if r}
if not args.repo and len(named) > 1:
_gitea.die("all ids must belong to one repo, got: %s" % ", ".join(sorted(named)))
repo_arg = args.repo or (sorted(named)[0] if named else None)
if args.dry_run:
for id, number, _repo in targets:
iss = issues.get(id)
where = ("%s (state: %s)" % (issue.path_of(root, id), iss.state)
if iss is not None else "no local copy")
print("would %s %s #%d%s" % (verb, id or "?", number, where))
print("%d issue(s) would be %s; no request was made"
% (len(targets), past))
return
login = _gitea.require_login()
base = _gitea.repo_base(repo_arg)
touched = 0
for id, number, _repo in targets:
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH",
{"state": state}, payload_name="state-%d" % number,
out_root=root)
# The gate. Above it nothing local has been written; below it the file
# is about to say something the tracker had better agree with.
if not confirmed(got, number, state):
_gitea.die("#%d: %s failed — the tracker's answer does not confirm the "
"write (%.200r). Nothing local was changed."
% (number, verb, got))
print("%s %s #%d %s" % (past, id or "?", number,
got.get("html_url", "")))
iss = issues.get(id)
if iss is None:
print(" no local copy — pull.py %d to get one" % number)
continue
print(" state: %s %s" % (state, apply_state(root, iss, state, got)))
touched += 1
if touched:
path, n = issue_index.build(root)
print("index: %s%d issue(s)" % (path, n))
if __name__ == "__main__":
main()