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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
---
|
||||
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`.
|
||||
Reference in New Issue
Block a user