434 lines
23 KiB
Markdown
434 lines
23 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` through
|
||
`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
|
||
not merely the same file, so a directory where `tea` works is a directory where
|
||
these work. That includes a **git worktree**, whose untracked pin sits in the
|
||
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
|
||
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
|
||
`/tea:auth`.
|
||
|
||
| Script | What it does |
|
||
|---|---|
|
||
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
|
||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
|
||
| `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 --no-deps # this issue only
|
||
```
|
||
|
||
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.
|
||
|
||
**`--limit N` bounds the write, not the selection.** N is how many issues this
|
||
run leaves in the store — written, or left in place by `--cached`. Closed ones
|
||
that were enumerated and thrown away do not spend it, so `--limit 20` over a
|
||
milestone whose first 30 issues are closed still writes 20, as long as 20 open
|
||
ones are there to write. Pagination follows the budget rather than the other way
|
||
round:
|
||
|
||
| | |
|
||
|---|---|
|
||
| budget full | the next page is never requested |
|
||
| pages run out | fewer than N, and that is the honest answer |
|
||
| filter matches almost only closed issues | at most 4× the pages N would need if nothing were dropped, then a warning on stderr and a short answer — raising `--limit` raises that ceiling too |
|
||
| dependencies | outside the count: a blocker is followed because a stored issue named it, not because the filter selected it — so `--limit 20` can leave more than 20 files behind |
|
||
|
||
`remote.py --limit` means something else, deliberately: it caps the **listing**,
|
||
closed issues included. It writes nothing, so there is no write for a limit to
|
||
bound — enumeration is its whole job.
|
||
|
||
**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 one request for its links and no other.
|
||
|
||
**Dependencies come with every pull too, and this one costs.** A pull answers
|
||
with the unit of work — the issue and what blocks it — so `depends:` is filled
|
||
from Gitea's native graph and every blocker is pulled as well, recursively, down
|
||
to `--depth` (default 3). It has to come from the native graph: the body's
|
||
`## Depends on` section holds slugs, never `#N`, so there is no edge to recover
|
||
from the text. `--no-deps` turns off both halves. `--deps` is still accepted and
|
||
does nothing — it names the default.
|
||
|
||
| | requests |
|
||
|---|---|
|
||
| every issue that lands in the store | **+1** — `GET …/issues/{n}/dependencies`, fetched once and used twice (fills `depends:`, steers the walk) |
|
||
| every blocker the selection did not already carry | **+1** to fetch it, then its own links, until `--depth` |
|
||
| a closed issue filter mode drops | 0 — nothing was stored, so there is no unit of work to complete |
|
||
| `--milestone X` over 50 open issues | 1 list request + 50, plus a pair per outside blocker — it used to be 1 |
|
||
| the same with `--no-deps` | 1 |
|
||
|
||
**A blocker the filter did not select still lands in the store, deliberately.**
|
||
`--milestone X` can leave an issue from milestone Y on disk; `--label` can leave
|
||
an unlabelled one. It is there because a stored issue names it, not because it
|
||
matched. The exception is a closed blocker: closed is not a unit of work, filter
|
||
mode drops it like any other closed issue, and the `depends:` edge to it goes
|
||
with it — nothing is left pointing at a file that is not there. Key mode
|
||
(`pull.py 42`) has no such rule and stores it.
|
||
|
||
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, every `pull.py` 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` (default; `--no-deps` off) | 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.
|
||
|
||
Labels belong to the repository, not to any issue, so this one runs on a
|
||
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
|
||
and nothing creates it. The request bodies go to `tmp/payload/` (below).
|
||
|
||
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.
|
||
|
||
The branch comes from the **current directory**, so run `push.py` from the tree
|
||
the work is on. In a git worktree that is the worktree, and it is now also
|
||
where the pin resolves from: the old workaround for the pin — run the scripts
|
||
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
|
||
is the one thing `branch:` exists to record.
|
||
|
||
## 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, every pull reads them (`--no-deps` opts out) |
|
||
| — | `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
|
||
|
||
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
|
||
first and passed as `-d @file`, then kept for a retry or a look at what actually
|
||
went up. One gitignored directory for all of them, chosen by the transport and
|
||
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
|
||
and it is never `tmp/issues/` — a command that touches no issue must not leave
|
||
an issue store behind.
|
||
|
||
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`.
|