e629d14585
Gitea becomes the source of truth. Once a push is confirmed, push.py
deletes tmp/issues/<id>.md and <id>.comments.md and prints the number and
URL the issue now lives at; the current state is obtained by pulling
again rather than by reconciling. --update follows the same rule, with no
exception: what is local is what has not left.
This reverses three statements AGENTS.md used to make, and rewriting them
is part of the change:
- "tmp/issues/ is the store, not a cache of Gitea" — it is both, split
by origin:. An origin: local file is the only copy of the work; an
origin: gitea file is a deletable working copy.
- "Pushing is additive: the file is never deleted" — it is deleted.
- "origin: local is a durable state" — complete, but not durable:
pushing ends it.
Slug stability, which the format promises for the life of an issue, can
no longer rest on a file push is about to delete. The slug goes up in the
body as a hidden marker, <!-- tea:id <slug> -->, on the first line:
map.to_payload strips every marker and prepends exactly one, map.from_api
strips every marker on the way down, so the local file never holds one
and a body cannot accumulate them however many round trips it makes. The
marker survives a rename in the web UI, a lost .remote.json, a fresh
clone and another machine — none of which a local index does.
Deletion is the last thing that happens to an issue and only after the
transport returned, the answer carried a positive integer number (and, on
--update, the number that was PATCHed — push.confirmed_number), and
.remote.json was written. A raised transport, a non-2xx, an empty or
mismatched body each leave the file on disk and stop the run.
.remote.json is no longer "only an index over the files": its entries now
deliberately outlive them, so it is the local number -> slug ledger and
rebuild_map merges into it instead of reconstructing it from files that
may be gone. It stays recoverable, from the markers in Gitea rather than
from the files. push.dep_state reads it too, so a blocker whose file an
earlier push dropped still gets its native dependency link.
Also fixes a pre-existing bug the new tests hit: issue.all_ids treated
<id>.comments.md as an issue called "<id>.comments", so a bare push.py in
a store holding pulled threads tried to file a comment thread as a unit
of work. A slug has no dot in it.
tests/test_drop_after_push.py covers the round trip (push -> gone -> pull
-> identical in slug, depends: and body), the marker's algebra, and every
failure path separately. test_push_dependencies.py is updated where it
encoded the old "never deleted" contract. 183 tests, no network.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
370 lines
19 KiB
Markdown
370 lines
19 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, **deletes the local file on success** and prints where it lives now |
|
|
| `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. While a working copy exists, the
|
|
pair is in the file:
|
|
|
|
```
|
|
origin: gitea
|
|
gitea: claude-skills/tea#42
|
|
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
|
synced: 2026-08-09T18:40:00Z
|
|
```
|
|
|
|
But the file is deleted on push, so the pair also lives in two places that
|
|
outlast it: `tmp/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
|
|
marker in the issue body on the Gitea side. See [How the slug comes
|
|
back](#how-the-slug-comes-back).
|
|
|
|
`.remote.json` used to be described as an index over the files. It is not one
|
|
any more — the files are a subset of what it knows, and its entries deliberately
|
|
outlive them. It is the local **ledger**, and `_gitea.rebuild_map` merges into it
|
|
rather than reconstructing it, so a rebuild can never drop a pushed issue.
|
|
Nothing prunes it: "no file" no longer means "no such issue". Delete it anyway
|
|
and nothing is lost — the next pull reads the slug off the marker and writes the
|
|
entry back.
|
|
|
|
A retitled issue keeps its slug: neither record is keyed by the title.
|
|
|
|
## 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 is how a pushed issue comes back.** Push deleted the file, so this is
|
|
not refreshing a copy you kept — it is how the copy comes to exist. It lands
|
|
under the same slug it had before, even after a rename in Gitea and even on a
|
|
machine that has never seen the issue; see [How the slug comes
|
|
back](#how-the-slug-comes-back).
|
|
|
|
**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
|
|
```
|
|
|
|
**A successful push DELETES the local file** — `tmp/issues/<id>.md` and
|
|
`<id>.comments.md` — and prints the number and URL the issue now lives at:
|
|
|
|
```
|
|
created wire-sqlc-appclick #42 https://git.noodles.cam/claude-skills/tea/issues/42
|
|
dropped /repo/tmp/issues/wire-sqlc-appclick.md
|
|
pull.py 42 to work on it again
|
|
```
|
|
|
|
Once the tracker has the issue, the tracker *is* the issue. What is left in the
|
|
store is what has not left this machine. There is no second copy, so there is
|
|
nothing to reconcile and no "is mine the fresh one?" to answer — see
|
|
[Drift](#drift).
|
|
|
|
**`--update` deletes too. One rule, no exception.** A PATCH is a push; an issue
|
|
that has just been sent is no more local than one that was just created. Edit an
|
|
issue by pulling it, changing it, pushing it — the copy is gone again after.
|
|
|
|
### What has to be true before anything is deleted
|
|
|
|
In order, and the delete is last:
|
|
|
|
1. the transport returned — `tea` ran and exited 0 (a non-2xx exits the run), and
|
|
2. the answer is an object carrying a positive integer `number`, and on
|
|
`--update` **the same number that was PATCHed** (`push.confirmed_number`), and
|
|
3. `.remote.json` has been written with number → slug.
|
|
|
|
Network down, a 422, an empty body, an answer for a different issue: the file is
|
|
still there and the run stops with the path in the error. An `origin: local`
|
|
issue that was not sent — including a local-only dependency that push only read
|
|
to warn about — is never touched. `--dry-run` deletes nothing and sends nothing.
|
|
|
|
### How the slug comes back
|
|
|
|
The slug is the issue's identity and the format promises it is stable for life,
|
|
so it cannot live only in a file that push is about to delete. Two records, and
|
|
the durable one is not local:
|
|
|
|
| where | survives | how |
|
|
|---|---|---|
|
|
| `<!-- tea:id wire-sqlc-appclick -->` | a rename in the web UI, a lost `.remote.json`, a fresh clone, another machine | first line of the **tracker-side** body; an HTML comment, so Gitea renders nothing |
|
|
| `tmp/issues/.remote.json` | the file being deleted | number → slug, written before the delete |
|
|
|
|
`pull.py` consults the ledger first (it is the one that knows about files on
|
|
disk right now), then the marker, then falls back to slugifying the title for an
|
|
issue filed in the web UI that has never had a local name. A marker is only
|
|
taken at its word when that slug is free — it never overwrites an issue already
|
|
in the store.
|
|
|
|
**The marker never appears in the local file.** `map.to_payload` puts exactly
|
|
one at the top on the way up, `map.from_api` strips every one on the way down.
|
|
Strip-all-then-prepend-one is the whole mechanism, which is why a body cannot
|
|
accumulate them however many round trips it makes, and why a body that somehow
|
|
gained two is cleaned on the next pull.
|
|
|
|
`depends:` survives the same round trip through Gitea's native links (below):
|
|
push writes them, `pull.py --deps` reads them back, and the ledger turns the
|
|
numbers into the slugs they had here.
|
|
|
|
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) | `<!-- tea:id … -->` | first line of the tracker-side body; stripped out of the local copy |
|
|
| title | `title` | verbatim, both directions |
|
|
| body | `body` | verbatim up except the marker; verbatim down except the marker and 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, and since push started deleting what it sends there is
|
|
very little left to track. A published issue has **one** copy — Gitea's —
|
|
except while somebody is working on it, and that window closes at the next
|
|
push. Nothing watches Gitea, nothing reconciles, nothing warns that a synced
|
|
issue changed upstream. `synced:` tells you how old your working copy is;
|
|
`remote-updated:` what the server said at that moment. Re-pull when it matters,
|
|
and push when you are done so there is nothing to be stale.
|
|
|
|
The old question — "I edited this locally, does the server have it, whose text
|
|
is newer?" — is answered by the store's contents rather than by a mechanism: a
|
|
file that is here has not been pushed.
|
|
|
|
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`.
|