d4c43464e5
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>
294 lines
14 KiB
Markdown
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`.
|