ISSUE_ROOT was the relative `tmp/issues`, so "the store" was whatever
directory the shell happened to be standing in. It is the --out default
in all eight scripts of both layers, which made one `cd` — and a `cd`
outlives the command that ran it — enough for readers to report an empty
store on a full one and for writers to quietly build a second store
beside the first. `issue_index.py` run from inside tmp/issues left
tmp/issues/tmp/issues/ behind and exited 0.
The anchor is issue.py's own __file__, not cwd. A script's location is a
fact about the installation; cwd is a fact about the last `cd`, and the
scripts are invoked by path from wherever the agent happens to be. From
there `store_root()` walks up to the nearest repo marker — `.git`
(exists(), not isdir(): a worktree's .git is a file) or AGENTS.md for a
copy taken out of git — and joins tmp/issues. Markers rather than a
fixed number of `..` hops, because the layout is not a promise. cwd is
tried only if the scripts are not inside a repository at all.
The function lives in the domain layer and skills/sync imports it, so
both layers agree by construction — the direction the layering rule
allows. skills/issue stays stdlib-only.
An explicit --out still wins and is used exactly as typed: a relative
--out stays relative to cwd, because that is what the operator asked
for. No new environment surface.
Two consequences the issue also asked for:
- Missing is no longer reported as empty. `store_error()` returns one
message for a path that is not there and another for a store with no
issues in it.
- Nothing conjures a store as a side effect of a write. save() and
issue_index.build() require it instead of os.makedirs'ing it; only
issue_new.py and pull.py create one, and both say so on stderr.
Establishes tests/ — plain stdlib unittest, no pytest, no dependencies.
The store tests build a throwaway repo in a TemporaryDirectory (a .git
marker, a copy of both script layers, fixture issues) and run the real
scripts inside it as subprocesses from five different working
directories; tmp/issues/ is never touched. Against the pre-fix scripts
15 of the 21 fail, reproducing the report exactly — five stray stores,
including tmp/issues/tmp/issues.
python3 -m unittest discover -s tests -v
Closes claude-skills/tea#15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 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. 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. |
/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, stamps gitea: on success |
comment.py <id> --file F | --body TEXT [--edit N] |
post or edit a comment, then refetch the thread |
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. The pair is recorded in the issue file itself:
origin: gitea
gitea: claude-skills/tea#42
url: https://git.noodles.cam/claude-skills/tea/issues/42
synced: 2026-08-09T18:40:00Z
tmp/issues/.remote.json indexes those fields for fast lookup. It is a cache
over the files, not a second source of truth — delete it and the next command
rebuilds it.
A retitled issue keeps its slug: the map is keyed by number, so a pull updates the existing file instead of creating a second one.
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 overwrites the local body. It is a fetch, not a merge — unpushed
local edits are lost. --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.
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
Pushing is additive: the local file is never deleted. It gains gitea:,
url:, synced:, and origin: flips to gitea. One issue, visible in two
places — not two kinds of file.
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.
Issues go up in topological order, dependencies first. A dependency that is
still local-only is reported, not silently dropped: the body's ## Depends on
prose is sent verbatim either way, but the #N cross-link will be missing
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.
What crosses the boundary, and what does not
| domain | Gitea | note |
|---|---|---|
id (slug) |
— | local only; the tracker never sees it |
| title, body | title, body |
verbatim, both directions |
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 |
— | slugs; seeded from #N on pull |
| — | 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.
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. The store is not a mirror: nothing watches Gitea,
nothing reconciles, nothing warns that a synced issue changed upstream.
synced: tells you how old your copy is; remote-updated: what the server
said at that moment. Re-pull when it matters.
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.