091dceec1d
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>
174 lines
7.6 KiB
Markdown
174 lines
7.6 KiB
Markdown
---
|
|
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/<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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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.
|
|
|
|
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`.
|