Files
marketplace/skills/sync/SKILL.md
T
naudachu 091dceec1d refactor: split issue domain from Gitea transport
An issue was a Gitea row that happened to be cached locally: its identity
was the tracker's number (42.md), its dependencies were tracker numbers
(depends: [#12]), and a local issue existed only as a draft that push
deleted on success. Nothing could be planned or tracked without a tracker.

Split into layers, with knowledge flowing one way:

  skills/issue  DOMAIN  what an issue is: format, validation, dep graph
        ^               offline; stdlib imports only, no subprocess
        | imports
  skills/sync   BRIDGE  map.py    md <-> Gitea JSON, pure, no I/O
                        _gitea.py login pin, api, pagination, filters
  skills/use    REFERENCE  tea CLI docs for non-issue entities

skills/issue never imports skills/sync. Delete the sync layer and the
domain keeps working.

Identity is now a slug derived from the title (wire-sqlc-appclick.md) and
is stable across retitles and pushes. Tracker numbers live in a `gitea:`
field, never in a file name and never in `depends:`; the pair is indexed
in .remote.json, which is a cache over the files, not a second source of
truth.

Behavior changes:

- Pushing is additive. The file is never deleted; it gains gitea:/url:/
  synced: and origin: flips from local to gitea. `origin: local` is a
  durable state, not a pending one.
- Pushes go in topological order so dependencies get numbers first.
- The dependency graph is computed offline from `depends:` metadata; body
  prose is passed through unchanged in both directions rather than being
  rewritten between slugs and #N.
- `origin` is domain-owned (whether work exists elsewhere is a fact about
  the work); the handle and how to reach it stay with sync.

Script moves:

  issue_get.py   -> sync/pull.py
  issue_push.py  -> sync/push.py
  issue_list.py  -> sync/remote.py
  issue_index.py -> issue/issue_index.py
  _tea.py        -> split into issue/issue.py, sync/map.py, sync/_gitea.py

New: issue/issue_new.py, issue/issue_check.py, issue/issue_tree.py, and
sync/comment.py — comment posting was the last issue operation still
hand-rolled through raw `tea api`.

references/issue-format.md moves to skills/issue/references/format.md;
label hex colors move out of it into map.py, since a color is how a
tracker paints a chip, not what an issue is.

Verified: offline path end to end (new, check, tree, index, push
--dry-run) and read-only against Gitea (remote listing, pull with
mapping, comment guard). Write paths of push.py and comment.py are not
exercised here.

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

7.6 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
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
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.

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.

A milestone must already exist in the repo — push attaches, it does not create.

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
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 --comments 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.