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>
21 KiB
name, description
| name | description |
|---|---|
| sync | 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
One job: translate between tmp/issues/<id>.md and Gitea's JSON, and carry the
result over the wire. Everything about what an issue is — format, types,
validation, the dependency graph — belongs to /tea:issue and is imported from
there, never redefined here.
Direction of knowledge, and it is one-way:
skills/issue domain what an issue is offline, no tracker
▲
│ imports
skills/sync bridge map.py md <-> Gitea JSON, pure, no I/O
_gitea.py login, tea api, pagination, filters
skills/issue never imports anything from here.
Never read an issue through raw tea
tea issues <n> -o json and tea api .../issues/<n> dump the full payload —
avatars, nested user objects, every comment body — into your context whether
you need it or not. Use pull.py: it writes flat markdown and prints a compact
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.
| 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 |
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 |
Key forms for <key>: 42, #42, owner/repo#42, or a full issue URL. Repo
defaults to the current directory's git remote; add --repo owner/repo outside
one.
--out defaults to issue.ISSUE_ROOT on every one of them — the domain layer's
<repo root>/tmp/issues, resolved from the scripts' own location rather than
cwd. Both layers therefore address the same store by construction, from any
directory. Pass --out to override; a relative one stays relative to cwd. Only
pull.py will create a missing store, and it says so on stderr.
Identity mapping
The local id is a slug; Gitea's is a number. While a working copy exists, the pair is in the file:
origin: gitea
gitea: claude-skills/tea#42
url: https://git.noodles.cam/claude-skills/tea/issues/42
synced: 2026-08-09T18:40:00Z
But the file is deleted on push, so the pair also lives in two places that
outlast it: tmp/issues/.remote.json (number → slug) and the <!-- tea:id … -->
marker in the issue body on the Gitea side. See How the slug comes
back.
.remote.json used to be described as an index over the files. It is not one
any more — the files are a subset of what it knows, and its entries deliberately
outlive them. It is the local ledger, and _gitea.rebuild_map merges into it
rather than reconstructing it, so a rebuild can never drop a pushed issue.
Nothing prunes it: "no file" no longer means "no such issue". Delete it anyway
and nothing is lost — the next pull reads the slug off the marker and writes the
entry back.
A retitled issue keeps its slug: neither record is keyed by the title.
Pulling
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
Do not loop over numbers to pull a group — pass the filter. The list endpoint
carries the issue bodies, so a milestone costs one request per 50 issues,
not one per issue. Filters AND together; --state defaults to open;
--limit to 100. Keys and filters are mutually exclusive.
A pull is how a pushed issue comes back. Push deleted the file, so this is not refreshing a copy you kept — it is how the copy comes to exist. It lands under the same slug it had before, even after a rename in Gitea and even on a machine that has never seen the issue; see How the slug comes back.
A pull overwrites the local body. It is a fetch, not a merge — unpushed
local edits are lost, with one exception: checkbox
state. --cached skips issues already on
disk.
Closed issues stay out of the store. In filter mode they are enumerated
but not written: --state all still shows the whole picture, only --state closed puts one on disk, and the number left out goes to stderr. An issue
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.
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.
Two traps this handles for you:
- Gitea silently ignores an unresolvable milestone filter and returns the
whole backlog.
pull.pyresolves the milestone first (exiting with the real ones if it does not exist) and re-checks every returned issue locally. Never trust a rawtea api ...issues?milestones=Xfor this. - Projects are not fetchable. The projects API is not exposed (404 on
Gitea 1.26 for
repos/…/projects,orgs/…/projects,projects/{id}). Use milestones or labels; project columns live in the web UI only.
After a pull, draw the graph with /tea:issue's issue_tree.py — offline, no
extra requests.
Checkboxes are the one exception
A checkbox is state, not prose, and it is the one thing a pull does not
overwrite. For a checkbox line whose text matches a line in the local copy,
[x] wins from whichever side has it — tick it in the web UI, tick it locally,
tick it in both, the tick survives.
| part of the body | what a pull does to it |
|---|---|
| prose, headings, everything not a checkbox | overwritten from the server, whole, as before |
| a checkbox whose text is in the local copy | [x] from either side wins |
| a checkbox whose text is not in the local copy | taken from the server as it stands, ticked or not |
| any issue the store has never seen | written exactly as the server sent it |
This is not drift tracking — Drift stands. A tick is monotone: an
item only travels [ ] → [x], so joining the two sides is a set union, not a
conflict to resolve. No base version is kept and nothing is compared against
one; one rule for one line type replaces the whole mechanism.
The price, and it is real: a box unticked in the web UI comes back on the next
pull. Unticking is not monotone, so the union cannot see it. Untick locally,
then push.py --update — the body goes up whole and the server follows.
Matching is on the item's text after the domain parser has stripped it and rejoined wrapped lines with single spaces, so rewrapping a long item keeps its tick. Rewording one does not: different text is a different item. The same text twice in a body is read as a set — one ticked local copy ticks every server line with that text.
The parsing is /tea:issue's (issue.checkboxes / issue.set_checkbox),
imported, never reimplemented here. The rule itself is
map.merge_checkbox_state: pure, and testable without a Gitea anywhere.
Pushing
python3 <skill-base-dir>/scripts/push.py --dry-run # validate, no network
python3 <skill-base-dir>/scripts/push.py # every local-only issue
python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
A successful push DELETES the local file — tmp/issues/<id>.md and
<id>.comments.md — and prints the number and URL the issue now lives at:
created wire-sqlc-appclick #42 https://git.noodles.cam/claude-skills/tea/issues/42
dropped /repo/tmp/issues/wire-sqlc-appclick.md
pull.py 42 to work on it again
Once the tracker has the issue, the tracker is the issue. What is left in the store is what has not left this machine. There is no second copy, so there is nothing to reconcile and no "is mine the fresh one?" to answer — see Drift.
--update deletes too. One rule, no exception. A PATCH is a push; an issue
that has just been sent is no more local than one that was just created. Edit an
issue by pulling it, changing it, pushing it — the copy is gone again after.
What has to be true before anything is deleted
In order, and the delete is last:
- the transport returned —
tearan and exited 0 (a non-2xx exits the run), and - the answer is an object carrying a positive integer
number, and on--updatethe same number that was PATCHed (push.confirmed_number), and .remote.jsonhas been written with number → slug.
Network down, a 422, an empty body, an answer for a different issue: the file is
still there and the run stops with the path in the error. An origin: local
issue that was not sent — including a local-only dependency that push only read
to warn about — is never touched. --dry-run deletes nothing and sends nothing.
How the slug comes back
The slug is the issue's identity and the format promises it is stable for life, so it cannot live only in a file that push is about to delete. Two records, and the durable one is not local:
| where | survives | how |
|---|---|---|
<!-- tea:id wire-sqlc-appclick --> |
a rename in the web UI, a lost .remote.json, a fresh clone, another machine |
first line of the tracker-side body; an HTML comment, so Gitea renders nothing |
tmp/issues/.remote.json |
the file being deleted | number → slug, written before the delete |
pull.py consults the ledger first (it is the one that knows about files on
disk right now), then the marker, then falls back to slugifying the title for an
issue filed in the web UI that has never had a local name. A marker is only
taken at its word when that slug is free — it never overwrites an issue already
in the store.
The marker never appears in the local file. map.to_payload puts exactly
one at the top on the way up, map.from_api strips every one on the way down.
Strip-all-then-prepend-one is the whole mechanism, which is why a body cannot
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
numbers into the slugs they had here.
Before anything is sent, /tea:issue's validator runs (exactly one type/*,
at most one severity/*, English title with no type prefix, ## Summary /
## Spec / ## Acceptance criteria present). --force posts anyway — say why
when you use it.
Dependencies
Issues go up in topological order, dependencies first, and the graph goes up
with them. Once an issue has its number, every depends: entry that also has
one becomes a native Gitea link, so the tracker shows the blocking panel and
refuses to close a blocked issue before its blocker.
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 |
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
url depend on the issue in the form"). owner/repo travel with it, so a
dependency in another repo links correctly.
- Topological order means the blocker already has its number — no second pass.
- A link the tracker already has is skipped: push GETs the existing ones first, so a repeat push is a no-op and a 409 never happens. Should a link fail anyway, it is a warning, not a dead run — the issues are already created.
--updatecarries links that appeared independs:after the first push.--dry-runprints every link it would make (#?for a number this run has not handed out yet) and makes no request at all.- Removing a link is out of scope. Push only adds. A dependency deleted
from
depends:leaves its Gitea link standing; drop it in the web UI or withtea api -X DELETE …/issues/N/dependencies.
A dependency that is still local-only is reported, not silently dropped: it has
no number, so it gets no link. The body's ## Depends on prose is sent verbatim
either way — nothing is lost, but the tracker shows no edge until that issue is
pushed too.
Missing labels are created with the canonical color and, for type/* and
severity/*, exclusive: true — tea labels create cannot set that field
(tea 0.14.2), so it goes through tea api. Colors live in map.py; the names
and their meaning come from the domain taxonomy.
That is per-push and piecemeal: a repo only ever grows the labels its issues
happened to use, so filtering by type/bug in the web UI stays impossible
until someone pushes a bug. labels.py lays down the whole set — the 11
type/* and severity/* names — in one run:
python3 <skill-base-dir>/scripts/labels.py --dry-run # the plan, no writes
python3 <skill-base-dir>/scripts/labels.py # create what is missing
It reads the repo's labels first. An exactly-matching name is never re-created
and never patched. A lookalike — bug, Bug, type: bug, kind/bug —
is reported with its id and left alone: renaming somebody else's label is a
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.
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
an empty one with the current git branch (git rev-parse --abbrev-ref HEAD)
and writes it back into the issue file; a value already there is never
overwritten, neither on create nor on --update. On a detached HEAD or outside
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
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 |
|---|---|---|
id (slug) |
<!-- tea:id … --> |
first line of the tracker-side body; stripped out of the local copy |
| title | title |
verbatim, both directions |
| body | body |
verbatim up except the marker; verbatim down except the marker and checkbox state, which is unioned |
state |
state |
same vocabulary |
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 |
| — | ref |
lands in branch:; sent only when non-empty |
| — | number, html_url |
lands in gitea: / url: |
depends: is always slugs. The body's ## Depends on section is human prose
and is passed through unchanged in both directions: a pull seeds depends:
from the #N it finds there, a push never rewrites what the author wrote. A
translator that edits prose churns the body on every round trip. The edge the
tracker acts on is the native link, not the text — which is exactly why the
text can be left alone.
Comments are pull-only in the store: <id>.comments.md is written by
pull.py and comment.py, and editing it by hand changes nothing in Gitea.
Drift
There is none tracked, and since push started deleting what it sends there is
very little left to track. A published issue has one copy — Gitea's —
except while somebody is working on it, and that window closes at the next
push. Nothing watches Gitea, nothing reconciles, nothing warns that a synced
issue changed upstream. synced: tells you how old your working copy is;
remote-updated: what the server said at that moment. Re-pull when it matters,
and push when you are done so there is nothing to be stale.
The old question — "I edited this locally, does the server have it, whose text is newer?" — is answered by the store's contents rather than by a mechanism: a file that is here has not been pushed.
Checkbox state is not an exception to this. The union a pull applies reads only the two bodies in front of it — there is no base version, no history, and no way for it to report that anything diverged. One rule for one line type, precisely so the mechanism this section rules out is not needed.
Rich payloads for everything else
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
empty-looking positional triggers the $EDITOR fallback on a TTY that does not
exist, and the harness eventually kills the process (exit 144 = 128 + SIGURG on
macOS). Write the JSON payload to $PWD/tmp/ first and POST it with
tea api -d @file. Procedure and endpoint table: /tea:use.
Login
Every tea call made by hand must carry the literal placeholder
--login "$GITEA_LOGIN"; the tea-guard hook substitutes the operator's pin.
Set it with /tea:auth. Details in /tea:use.