Files
marketplace/skills/sync/SKILL.md
T
naudachu d4c43464e5 feat: merge checkbox state on pull instead of overwriting it
A tick was lost in both directions: pull wrote the server body as-is,
push sent the local body as-is, last writer won. Tick it in the web UI
and the first `push.py --update` dropped it; tick it locally and the
first pull dropped it.

The usual answer is drift tracking and a three-way merge, which this
repo rejected on purpose. It is not needed. A tick is monotone — an item
only travels `[ ]` -> `[x]` — so unioning the two sides is a set union,
not conflict resolution. One rule for one line type replaces the whole
mechanism, and the store stays "not a mirror".

`map.merge_checkbox_state` is pure and does the work; `from_api` takes
the local body as an optional argument; `pull.py` hands it the copy
already on disk. Checkbox parsing is imported from `skills/issue`
(`checkboxes` / `set_checkbox`), never redefined here — the domain layer
is untouched.

The same item text more than once is read as a set: one ticked local
item ticks every server line with that text. Pairing duplicates up by
order is the alternative, and it can still drop a tick — which is the
bug being fixed.

The price is documented, not hidden: unticking is not monotone, so a box
unticked in the web UI comes back on the next pull. Untick locally, then
push.

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

294 lines
14 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`, 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.
`--out` defaults to `issue.ISSUE_ROOT` on every one of them — the domain layer's
`<repo root>/tmp/issues`, resolved from the scripts' own location rather than
cwd. Both layers therefore address the same store by construction, from any
directory. Pass `--out` to override; a relative one stays relative to cwd. Only
`pull.py` will create a missing store, and it says so on stderr.
## 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, with one exception: [checkbox
state](#checkboxes-are-the-one-exception). `--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.
### Checkboxes are the one exception
A checkbox is state, not prose, and it is the one thing a pull does **not**
overwrite. For a checkbox line whose **text** matches a line in the local copy,
`[x]` wins from whichever side has it — tick it in the web UI, tick it locally,
tick it in both, the tick survives.
| part of the body | what a pull does to it |
|---|---|
| prose, headings, everything not a checkbox | overwritten from the server, whole, as before |
| a checkbox whose text is in the local copy | `[x]` from **either** side wins |
| a checkbox whose text is not in the local copy | taken from the server as it stands, ticked or not |
| any issue the store has never seen | written exactly as the server sent it |
This is not drift tracking — [Drift](#drift) stands. A tick is **monotone**: an
item only travels `[ ]``[x]`, so joining the two sides is a set union, not a
conflict to resolve. No base version is kept and nothing is compared against
one; one rule for one line type replaces the whole mechanism.
**The price, and it is real: a box unticked in the web UI comes back on the next
pull.** Unticking is not monotone, so the union cannot see it. Untick locally,
then `push.py --update` — the body goes up whole and the server follows.
Matching is on the item's text after the domain parser has stripped it and
rejoined wrapped lines with single spaces, so rewrapping a long item keeps its
tick. Rewording one does not: different text is a different item. The same text
twice in a body is read as a set — one ticked local copy ticks every server line
with that text.
The parsing is `/tea:issue`'s (`issue.checkboxes` / `issue.set_checkbox`),
imported, never reimplemented here. The rule itself is
`map.merge_checkbox_state`: pure, and testable without a Gitea anywhere.
## 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.
### Dependencies
Issues go up in topological order, dependencies first, and **the graph goes up
with them**. Once an issue has its number, every `depends:` entry that also has
one becomes a native Gitea link, so the tracker shows the blocking panel and
refuses to close a blocked issue before its blocker.
The two directions are symmetric, and they use the same endpoint:
| | direction | endpoint |
|---|---|---|
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
| `pull.py --deps` | native links → `depends:` | `GET …/issues/{n}/dependencies` |
The POST body is Gitea's `IssueMeta``{"index", "owner", "repo"}` naming the
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
url depend on the issue in the form"). `owner`/`repo` travel with it, so a
dependency in another repo links correctly.
- Topological order means the blocker already has its number — no second pass.
- A link the tracker already has is skipped: push GETs the existing ones first,
so a repeat push is a no-op and a 409 never happens. Should a link fail
anyway, it is a warning, not a dead run — the issues are already created.
- `--update` carries links that appeared in `depends:` after the first push.
- `--dry-run` prints every link it would make (`#?` for a number this run has
not handed out yet) and makes no request at all.
- **Removing a link is out of scope.** Push only adds. A dependency deleted
from `depends:` leaves its Gitea link standing; drop it in the web UI or with
`tea api -X DELETE …/issues/N/dependencies`.
A dependency that is still local-only is reported, not silently dropped: it has
no number, so it gets no link. The body's `## Depends on` prose is sent verbatim
either way — nothing is lost, but the tracker shows no edge 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.
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:
```bash
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 **lookalike**`bug`, `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 | `title` | verbatim, both directions |
| body | `body` | verbatim up; verbatim down except checkbox state, which is unioned |
| `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` | native links | slugs here, `IssueMeta` there; push writes them, `pull --deps` reads them |
| — | `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. The edge the
tracker acts on is the native link, not the text — which is exactly why the
text can be left alone.
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.
Checkbox state is not an exception to this. The union a pull applies reads only
the two bodies in front of it — there is no base version, no history, and no
way for it to report that anything diverged. One rule for one line type,
precisely so the mechanism this section rules out is not needed.
## 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`.