Files
marketplace/skills/sync/SKILL.md
T
naudachu 9234d8004f feat: work the sync backlog — comments, labels, refs, closed issues
Five tracker issues, all in the bridge layer except the last.

pull.py fetches comments by default (#6). The thread was reachable only
through --comments, and only for a single issue, so a bulk pull left every
local copy silently incomplete: a missing <id>.comments.md could mean "no
comments" or "never asked". Now every written issue gets its thread, in key
and filter mode alike; an empty one costs no request (the count rides in the
list payload) and writes no file, and a file left over from an earlier pull
is deleted. --cached skips the thread along with the body. The --comments
flag is gone.

labels.py bootstraps the canonical label set (#7). Labels used to appear as a
side effect of the first push that happened to use them, so a repo could not
be filtered by type/bug until somebody pushed a bug. The set is finite and
already described by the domain taxonomy — 6 type/* and 5 severity/* — which
makes it a run, not a decision. Names and exclusivity come from issue.TYPES /
SEVERITIES / EXCLUSIVE_NS, colors from map.label_specs; no list is duplicated.
An exact name is never re-created or patched. Lookalikes (bug, Bug, "type:
bug", kind/bug) are reported with their id and left alone — renaming somebody
else's label is a decision, not a migration. Color or exclusive drift is
printed, and changed only under --fix.

branch: carries Gitea's ref (#8). map.to_payload sends ref only when the field
is non-empty, since ref="" would clear whatever the server has; from_api reads
it back; push fills an empty one from `git rev-parse --abbrev-ref HEAD` and
writes it into the issue file. A hand-written value is never overwritten, on
create or on --update. Detached HEAD and running outside a repo warn and send
no ref. Reading the branch is the only thing these scripts ask of git. The
domain needs no change: unknown keys already ride in Issue.extra and render
after the domain fields.

Bulk pulls no longer store closed issues (#10). Filter mode wrote every
payload the server returned, so --state all dragged the closed backlog into a
store that gets read whole — INDEX.md, grep over tmp/issues/*.md. They are
still enumerated, the number left out goes to stderr, and an issue already on
disk is refreshed either way so the local copy learns it was closed instead of
staying open forever. --state closed stores them, and key mode is exempt: an
address is not a bulk read.

/tea:issue gains a "Writing a proper description" procedure (#9). Six steps
from reading an issue to issue_check.py, the rule that a missing fact is found
in the repository or asked about rather than invented, and the note that the
procedure is identical for origin: local and origin: gitea while delivery to
the tracker belongs to /tea:sync. No new script.

Verified: labels.py run for real against claude-skills/tea (9 created, 2
already present) and idempotent on a second run; pull.py exercised live for
the closed-skip, --state closed, key-mode and comment paths; the push write
path covered offline with the transport stubbed. skills/issue/scripts/ still
imports stdlib only, with no subprocess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:37:57 +05:00

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.

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.py resolves the milestone first (exiting with the real ones if it does not exist) and re-checks every returned issue locally. Never trust a raw tea api ...issues?milestones=X for 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: truetea 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 lookalikebug, 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.