feat: close issues through a script #25

Merged
claude merged 2 commits from feat/close-script into main 2026-08-10 13:29:37 +00:00
Collaborator

Closes #17.

Adds skills/sync/scripts/close.py — the only regular tracker operation that
had no script behind it. Until now the only way to move state: was a raw
api -X PATCH call naming the owner, the repo and the request body by hand:
everything the transport layer exists to hide, and a permission wide enough to
cover -X DELETE on the repository.

What it does

close.py wire-sqlc-appclick        # by slug
close.py 42 '#43'                  # by number
close.py --reopen 42
close.py --dry-run 42 43           # no request at all
  • State only. The payload carries state and nothing else. Closing is not
    an edit; editing stays pull.py -> change -> push.py --update.
  • Explicit ids only. No --milestone, no --label: which issues are
    finished is a judgement about content. Deleting an issue stays out of scope.
  • Slug or key. A slug resolves through its gitea: field while the file is
    there and through .remote.json after push dropped it, so an issue with no
    local copy is still closeable by name. Keys take the same four forms
    pull.py accepts.
  • origin: local is refused — it is not in the tracker, so there is no
    state there to change, and the error names the id.
  • One run, one repo. A key that names its own repo is sent there, not to
    whatever repo the CWD happens to be in.

Safety

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. Arguments are all resolved before anything is sent,
so a typo in the third id cannot leave the first two closed. --dry-run makes
no request at all and needs no pinned login.

Layering

close.py lives in the bridge, goes through _gitea.py, and adds no second
transport. Both domains are untouched and still stdlib-only.

Docs

  • skills/sync/SKILL.md — command table row plus a "Closing and reopening"
    section (what may be named, what happens to the local copy, what the
    confirmation gate requires).
  • agents/tea-runner.md — rule 4 rewritten: closing was forbidden because
    nothing but a raw call could do it, not because it is dangerous. The runner
    may now close the ids the caller named and no others; deleting and retitling
    stay forbidden.
  • AGENTS.md, skills/issue/SKILL.md — script list and the pointer for
    closing a synced issue.

Tests

tests/test_close.py (54 cases) stubs the transport at _gitea.api and, for
the non-2xx path, one layer lower at _gitea.subprocess so a CLI exiting 1 is
proved end to end. Every store is a tempfile.TemporaryDirectory(); nothing
touches tmp/issues/ or a network.

python3 -m unittest discover -s tests
Ran 286 tests — OK
Closes #17. Adds `skills/sync/scripts/close.py` — the only regular tracker operation that had no script behind it. Until now the only way to move `state:` was a raw `api -X PATCH` call naming the owner, the repo and the request body by hand: everything the transport layer exists to hide, and a permission wide enough to cover `-X DELETE` on the repository. ## What it does ```bash close.py wire-sqlc-appclick # by slug close.py 42 '#43' # by number close.py --reopen 42 close.py --dry-run 42 43 # no request at all ``` - **State only.** The payload carries `state` and nothing else. Closing is not an edit; editing stays `pull.py` -> change -> `push.py --update`. - **Explicit ids only.** No `--milestone`, no `--label`: which issues are finished is a judgement about content. Deleting an issue stays out of scope. - **Slug or key.** A slug resolves through its `gitea:` field while the file is there and through `.remote.json` after push dropped it, so an issue with no local copy is still closeable by name. Keys take the same four forms `pull.py` accepts. - **`origin: local` is refused** — it is not in the tracker, so there is no state there to change, and the error names the id. - **One run, one repo.** A key that names its own repo is sent there, not to whatever repo the CWD happens to be in. ## Safety 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. Arguments are all resolved before anything is sent, so a typo in the third id cannot leave the first two closed. `--dry-run` makes no request at all and needs no pinned login. ## Layering `close.py` lives in the bridge, goes through `_gitea.py`, and adds no second transport. Both domains are untouched and still stdlib-only. ## Docs - `skills/sync/SKILL.md` — command table row plus a "Closing and reopening" section (what may be named, what happens to the local copy, what the confirmation gate requires). - `agents/tea-runner.md` — rule 4 rewritten: closing was forbidden because nothing but a raw call could do it, not because it is dangerous. The runner may now close the ids the caller named and no others; deleting and retitling stay forbidden. - `AGENTS.md`, `skills/issue/SKILL.md` — script list and the pointer for closing a synced issue. ## Tests `tests/test_close.py` (54 cases) stubs the transport at `_gitea.api` and, for the non-2xx path, one layer lower at `_gitea.subprocess` so a CLI exiting 1 is proved end to end. Every store is a `tempfile.TemporaryDirectory()`; nothing touches `tmp/issues/` or a network. ``` python3 -m unittest discover -s tests Ran 286 tests — OK ```
claude added 1 commit 2026-08-10 12:28:49 +00:00
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>
naudachu added 1 commit 2026-08-10 13:29:27 +00:00
Two conflicts git could see (AGENTS.md, skills/sync/SKILL.md) and one it could
not: the payload-root change removed api()'s out_root parameter, so close.py
stops passing it, and its payload test now asserts PAYLOAD_ROOT instead of the
deleted PAYLOAD_DIR.
claude merged commit 17567cd6a2 into main 2026-08-10 13:29:37 +00:00
Sign in to join this conversation.