--- 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. --- # /tea:sync — the bridge between the local store and Gitea One job: translate between `tmp/issues/.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 -o json` and `tea api .../issues/` 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 `/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 ` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/.md` | | `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success | | `comment.py --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 ``: `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 ```bash python3 /scripts/pull.py 42 python3 /scripts/pull.py --milestone 6 # id or title python3 /scripts/pull.py --label type/bug --state all python3 /scripts/pull.py -q sqlc --limit 20 python3 /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 ```bash python3 /scripts/push.py --dry-run # validate, no network python3 /scripts/push.py # every local-only issue python3 /scripts/push.py wire-sqlc-appclick python3 /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. 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: `.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`.