Merge origin/main into feat/evict-closed-issues

Three doc conflicts, all unions: the script lists in AGENTS.md and the runner
gain both close.py and evict.py, and the sync skill keeps both the closing and
the evicting sections. Rule 4 of the runner is rewritten once to carry both
halves — closing is now a script it may run on named ids, retitling and remote
deletion stay forbidden, and the two allowed local deletions (push's own, and
eviction) are listed together.
This commit is contained in:
naudachu
2026-08-10 18:32:39 +05:00
27 changed files with 3083 additions and 172 deletions
+123 -10
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
@@ -32,16 +32,22 @@ index.
## Scripts
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
operator's pin from `.claude/settings.local.json` themselves, the same source
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
operator's pin from `.claude/settings.local.json` through
`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
not merely the same file, so a directory where `tea` works is a directory where
these work. That includes a **git worktree**, whose untracked pin sits in the
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
`/tea:auth`.
| Script | What it does |
|---|---|
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing |
| `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 |
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
| `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 |
@@ -89,7 +95,7 @@ python3 <skill-base-dir>/scripts/pull.py 42
python3 <skill-base-dir>/scripts/pull.py --milestone 6 # id or title
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20
python3 <skill-base-dir>/scripts/pull.py 40 --deps # follow dependencies
python3 <skill-base-dir>/scripts/pull.py 40 --no-deps # this issue only
```
Do not loop over numbers to pull a group — pass the filter. The list endpoint
@@ -115,13 +121,55 @@ already on disk is refreshed either way — the local copy learns it was closed
instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a
closed issue as always, because an address is not a bulk read.
**`--limit N` bounds the write, not the selection.** N is how many issues this
run leaves in the store — written, or left in place by `--cached`. Closed ones
that were enumerated and thrown away do not spend it, so `--limit 20` over a
milestone whose first 30 issues are closed still writes 20, as long as 20 open
ones are there to write. Pagination follows the budget rather than the other way
round:
| | |
|---|---|
| budget full | the next page is never requested |
| pages run out | fewer than N, and that is the honest answer |
| filter matches almost only closed issues | at most 4× the pages N would need if nothing were dropped, then a warning on stderr and a short answer — raising `--limit` raises that ceiling too |
| dependencies | outside the count: a blocker is followed because a stored issue named it, not because the filter selected it — so `--limit 20` can leave more than 20 files behind |
`remote.py --limit` means something else, deliberately: it caps the **listing**,
closed issues included. It writes nothing, so there is no write for a limit to
bound — enumeration is its whole job.
**Comments come with every pull** — there is no flag. An issue that has a
thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter
mode alike, and the issue's output line says how many. An issue with none
costs nothing: the count arrives in the list payload, so no request is made
and no file is written — and a file left over from a thread that has since
been emptied is deleted. `--cached` skips the thread along with the body, so a
skipped issue makes no request at all.
skipped issue makes one request for its links and no other.
**Dependencies come with every pull too, and this one costs.** A pull answers
with the unit of work — the issue and what blocks it — so `depends:` is filled
from Gitea's native graph and every blocker is pulled as well, recursively, down
to `--depth` (default 3). It has to come from the native graph: the body's
`## Depends on` section holds slugs, never `#N`, so there is no edge to recover
from the text. `--no-deps` turns off both halves. `--deps` is still accepted and
does nothing — it names the default.
| | requests |
|---|---|
| every issue that lands in the store | **+1** — `GET …/issues/{n}/dependencies`, fetched once and used twice (fills `depends:`, steers the walk) |
| every blocker the selection did not already carry | **+1** to fetch it, then its own links, until `--depth` |
| a closed issue filter mode drops | 0 — nothing was stored, so there is no unit of work to complete |
| `--milestone X` over 50 open issues | 1 list request + 50, plus a pair per outside blocker — it used to be 1 |
| the same with `--no-deps` | 1 |
**A blocker the filter did not select still lands in the store, deliberately.**
`--milestone X` can leave an issue from milestone Y on disk; `--label` can leave
an unlabelled one. It is there because a stored issue names it, not because it
matched. The exception is a closed blocker: closed is not a unit of work, filter
mode drops it like any other closed issue, and the `depends:` edge to it goes
with it — nothing is left pointing at a file that is not there. Key mode
(`pull.py 42`) has no such rule and stores it.
Two traps this handles for you:
@@ -234,7 +282,7 @@ accumulate them however many round trips it makes, and why a body that somehow
gained two is cleaned on the next pull.
`depends:` survives the same round trip through Gitea's native links (below):
push writes them, `pull.py --deps` reads them back, and the ledger turns the
push writes them, every `pull.py` reads them back, and the ledger turns the
numbers into the slugs they had here.
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
@@ -254,7 +302,7 @@ The two directions are symmetric, and they use the same endpoint:
| | direction | endpoint |
|---|---|---|
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
| `pull.py --deps` | native links → `depends:` | `GET …/issues/{n}/dependencies` |
| `pull.py` (default; `--no-deps` off) | native links → `depends:` | `GET …/issues/{n}/dependencies` |
The POST body is Gitea's `IssueMeta``{"index", "owner", "repo"}` naming the
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
@@ -299,6 +347,10 @@ decision, not a migration. A color or `exclusive` that drifted is printed, and
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
`comp/*` are open-ended by design and stay push-created.
Labels belong to the repository, not to any issue, so this one runs on a
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
and nothing creates it. The request bodies go to `tmp/payload/` (below).
A milestone must already exist in the repo — push attaches, it does not create.
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
@@ -309,6 +361,60 @@ 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.
The branch comes from the **current directory**, so run `push.py` from the tree
the work is on. In a git worktree that is the worktree, and it is now also
where the pin resolves from: the old workaround for the pin — run the scripts
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
is the one thing `branch:` exists to record.
## 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.
## Evicting what the tracker says is closed
```bash
@@ -371,7 +477,7 @@ so there is no reason to start before every answer is in.
| `labels` | `labels[]` | names both ways; ids only on write |
| `assignees` | `assignees[]` | logins |
| `milestone` | `milestone.title` | resolved to an id on write |
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, `pull --deps` reads them |
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, every pull reads them (`--no-deps` opts out) |
| — | `ref` | lands in `branch:`; sent only when non-empty |
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
@@ -406,6 +512,13 @@ precisely so the mechanism this section rules out is not needed.
## Rich payloads for everything else
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
first and passed as `-d @file`, then kept for a retry or a look at what actually
went up. One gitignored directory for all of them, chosen by the transport and
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
and it is never `tmp/issues/` — a command that touches no issue must not leave
an issue store behind.
Comments and issues are wrapped by the scripts above. For **other** entities
(pulls, releases, PATCHing something these scripts do not cover), entity
subcommands like `tea pulls create` hang on a large or formatted body — an