46b6909728
`_gitea.require_login` walked up from CWD and nowhere else. A worktree is a sibling of the main checkout, not a descendant, and `settings.local.json` is untracked — so the pin lives in the main checkout only, is not on the worktree's parent chain, and the whole tracker half of the plugin died there with "no login pinned". In the same directory the guard resolved it fine, because it had a search of its own: one order, written twice, disagreeing. It is written once now, in skills/auth/scripts/pin.py, and both callers import it — the transport and hooks/tea-guard.sh. $CLAUDE_PROJECT_DIR, then a hint the caller supplies (the hook passes its payload's cwd), then the current directory; each searched up its parent chain, and only if that finds nothing, across into the main working tree of a linked worktree met on the way, reached by reading `gitdir:` out of the `.git` FILE and following `commondir`. No subprocess — a PreToolUse hook runs before every Bash call and must not fork to answer this. The search still starts at the working directory and never at `__file__`, deliberately asymmetric with `issue.store_root` and `_gitea.PAYLOAD_ROOT`. Where an installation keeps its files is a fact about the installation; whose login a project runs under is a fact about the project, and a plugin pointed at somebody else's tree must not answer that from its own directory. pin.py says so in as many words, so the next reader does not "fix" the inconsistency. Two consequences fall out of it. `/tea:auth` no longer has any reason to run inside a worktree, so no second pin lands in a directory that is deleted with the branch — the skill now says to write it beside the common `.git`. And the scripts can run where the work is: the workaround the bug forced, cwd in the main checkout, made push.py send that checkout's branch as `ref`, which is the one thing `branch:` exists to record. tests/test_login_pin.py holds both halves: the hop against a hand-built layout and against a real `git worktree add`, a run from the worktree finding the login, no pin anywhere still erroring, the scripts' own directory not becoming a source, `ref` coming out as the worktree's branch, and the hook and a script answering the same directory alike. Two mechanical checks keep the callers from growing a second copy of the walk. Three existing fixtures now copy skills/auth/scripts, which the transport imports. Refs #24. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
392 lines
20 KiB
Markdown
392 lines
20 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]` | 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.
|
|
|
|
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, `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
|
|
|
|
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`.
|