refactor: turn the repo into a two-plugin marketplace

tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.

The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.

tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.

test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-11 00:25:28 +05:00
parent 23f78beafb
commit 83f73c5cea
69 changed files with 4187 additions and 168 deletions
+537
View File
@@ -0,0 +1,537 @@
---
name: sync
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen 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 |
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
| `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 sends it up as `ref`; a value already there is never
overwritten, neither on create nor on `--update`. Nothing is written back to
the issue file — there is no file left to write to, because a successful push
deletes it. The branch comes back on disk with the next `pull.py <n>`, from
the tracker. 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.
## Closing and reopening
```bash
python3 <skill-base-dir>/scripts/close.py wire-sqlc-appclick # by slug
python3 <skill-base-dir>/scripts/close.py 42 '#43' # by number
python3 <skill-base-dir>/scripts/close.py --reopen 42
python3 <skill-base-dir>/scripts/close.py --dry-run 42 43 # no request at all
```
`close.py` is the only supported way to move `state:`. Never hand-roll
`tea api -X PATCH -d '{"state":"closed"}' repos/OWNER/REPO/issues/N`: it spells
out the owner, the repo and the request body — the three things this layer
exists to hide — and it needs a `Bash(tea api *)` permission that also covers
`-X DELETE` on the repository.
**State only.** The payload is `{"state": …}` and nothing else — no title, no
body, no labels, no milestone. Closing is not an edit; editing is `pull.py`
change → `push.py --update`.
**Explicit ids only.** There is no `--milestone` and no `--label`: which issues
are finished is a judgement about content, and this script only carries one
out, one named id at a time. Deleting an issue is out of scope too — Gitea can,
and it is not an operation of this workflow.
What may be named, and what happens to the local copy:
| named | resolved through | local file |
|---|---|---|
| a slug with a file on disk | its `gitea:` field | `state:` rewritten, `synced:` refreshed |
| a slug whose file push dropped | `.remote.json` | none to write — say so and move on |
| `42`, `#42`, `owner/repo#42`, a URL | the key itself; the ledger supplies the slug | rewritten when a file of that slug is there |
| a slug with `origin: local` | — | **refused**: it is not in the tracker, and the error names the id |
The local file is written only after the tracker has confirmed *this* write: an
object carrying the very number that was PATCHed, in the state that was asked
for. A non-2xx, a `tea` that would not run, an answer for another issue, a 200
that still says `open` — the run stops and the file is byte for byte what it
was. `--dry-run` prints the same lines and makes no request at all, so it needs
no pinned login.
Gitea refuses to close an issue that its own dependency graph still blocks. The
refusal arrives as a non-2xx with the tracker's own words: close the blockers
first, or unlink them in the web UI.
The index is rebuilt when at least one local file changed, so `INDEX.md` never
outlives the state it reports. Nothing is deleted here — unlike a push, a close
leaves the working copy where it is.
## Evicting what the tracker says is closed
```bash
python3 <skill-base-dir>/scripts/evict.py --dry-run # ask, report, change nothing
python3 <skill-base-dir>/scripts/evict.py # and remove them
python3 <skill-base-dir>/scripts/evict.py old-thing # just this one
```
Eviction itself belongs to `/tea:issue` (`issue_evict.py`) and is offline: the
decision is `state: closed` plus an `origin:` that names a tracker, both read
off the file. This script adds one thing in front of it — a `state:` that is not
stale — and then calls that same decision. There is one implementation of "what
may be evicted" and it is in the domain.
Why it exists: a local `state:` is only as fresh as the last pull, so an issue
closed in the web UI still reads `open` here and the offline command correctly
leaves it alone. The workaround was `pull.py 11 12 13 14 15` — which writes the
five closed files back to disk before anything can remove them.
Order of operations, and it is the safety argument:
1. every candidate's state is fetched — **all** of them, before anything is
removed;
2. each answer must be an object carrying the number that was asked about and a
state the domain recognizes (`evict.confirmed_state`, the counterpart of
`push.confirmed_number`);
3. only then does the eviction run.
**A failed call evicts nothing** — not even the candidates whose answers had
already arrived, and no refreshed `state:` is written back either. Stricter than
push, which deletes as it goes, and free: evictions have no order between them,
so there is no reason to start before every answer is in.
- A **candidate** is an issue carrying a `gitea:` handle. `origin: local` has
none, is never asked about, and is never removed. An `origin: gitea` issue
whose handle is missing or unparseable cannot be verified — it is reported on
stderr and kept.
- No `--repo`: the repo comes from each issue's own handle, so a store holding
issues from two repos is checked against both.
- One GET per candidate. The store is a working set that push keeps small, and a
wrong answer here deletes a file — so each issue is asked about by its own
address rather than inferred from a list a `--limit` could have truncated.
- A state that disagrees with the file is written back, so the store stops lying
about the issues that stay too. `--dry-run` makes no writes at all.
- `.remote.json` is not pruned; see [How the slug comes
back](#how-the-slug-comes-back) — an evicted issue is exactly as findable as a
pushed one.
- **`pull.py <n>` still fetches a closed issue.** A number is an address, not a
query. A closed issue pulled after an eviction is back on disk, and that is
the tracker answering the question it was asked, not a regression.
## 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`.
+511
View File
@@ -0,0 +1,511 @@
#!/usr/bin/env python3
"""
_gitea.py — transport. Everything that talks to Gitea, and nothing else.
Not a command. This module knows logins, HTTP verbs, pagination, and Gitea's
query quirks. It does NOT know what an issue is: no sections, no acceptance
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
lives one layer further out in skills/issue/scripts/issue.py.
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
Where that file is searched for is NOT written here — skills/auth/scripts/pin.py
owns the search order, and the tea-guard hook imports the same module, so `tea`
and the scripts can never disagree about which login a directory runs under. No
script here accepts a login argument: the operator's pin is the only identity
they will use. No pin -> exit with a pointer to /tea:auth.
Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with
a local slug, and the paths of the store-side files this layer writes. All of
it is transport bookkeeping, not domain data — the domain never reads any of
it, and losing the map still costs a re-pull and not information: the slug it
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
pull rebuilds the entry from the tracker. See `rebuild_map`.
Request bodies go to tmp/payload/, which is this module's own scratchpad and
NOT a store: nothing in it is anybody's only copy, and writing one must never
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
touches no issue at all — it used to leave a store behind anyway, because the
request file had nowhere else to live. One directory, every caller, resolved
from this file the way the two domains resolve theirs.
"""
import datetime
import json
import os
import subprocess
import re
import sys
import urllib.parse
REMOTE_MAP = ".remote.json"
# How far past the ideal page count a `keep`-bounded listing may scan before it
# gives up (see list_issues). The ideal is what `limit` would need if every
# payload counted; the slack pays for the ones that do not. It is a bound on
# requests, deliberately small: "fetch until N are kept" without one is "fetch
# the whole tracker" on any repo whose filter matches mostly closed issues.
PAGE_SLACK = 4
# --------------------------------------------------------------------------
# where request bodies land
# --------------------------------------------------------------------------
# Anchored on THIS FILE, like issue.store_root, so every caller — sync,
# whatever comes next — writes to one directory whatever it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
# inside somebody's store, because a scratchpad that looks like store contents
# is how this went wrong the first time. `tmp/` is already gitignored.
PAYLOAD_PARTS = ("tmp", "payload")
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
# git; the agents-sync hook only ever puts one at a repository root.
REPO_MARKERS = (".git", "AGENTS.md")
_HERE = os.path.dirname(os.path.abspath(__file__))
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
sys.exit(code)
def warn(msg):
sys.stderr.write("warning: %s\n" % msg)
def now_iso():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def repo_root(start):
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
d = os.path.abspath(start)
while True:
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
return d
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def payload_root(start=None):
"""Absolute path of the request-body scratchpad.
`start` overrides the anchor so the resolution can be exercised against a
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
location stands — made absolute so an error can name the directory it
really wrote to."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *PAYLOAD_PARTS)
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
PAYLOAD_ROOT = payload_root()
# --------------------------------------------------------------------------
# login
# --------------------------------------------------------------------------
# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the
# single written copy of the search order, and the tea-guard hook calls the
# same function. When the two had a copy each, a git worktree got a hook that
# resolved the pin and a transport that did not — in the same directory.
#
# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those
# are anchored on their own file, this is not, and both are right. 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 installed
# outside any repository must not answer it from its own directory. See the
# module docstring in pin.py.
_AUTH_SCRIPTS = os.path.abspath(
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
if _AUTH_SCRIPTS not in sys.path:
sys.path.append(_AUTH_SCRIPTS)
import pin # noqa: E402
def require_login():
"""The operator's pinned login, or exit pointing at /tea:auth.
No pin found is reported as exactly that. It stays a truthful message: the
fix for "the pin is somewhere this search does not reach" belongs in
pin.py, never in a hint here that sends the operator to pin it twice."""
login, _ = pin.find_pin()
if not login:
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
return login
# --------------------------------------------------------------------------
# api
# --------------------------------------------------------------------------
def api(login, endpoint, method="GET", payload=None, payload_name=None,
allow_fail=False):
"""Call `tea api`; return parsed JSON (None on an empty body).
payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
-d @file — the file survives the call for retries and debugging. Where
that is, is not the caller's business and never was: the directory is
this layer's scratchpad, and the one time it was a caller's decision it
got pointed at the issue store. allow_fail returns None instead of
exiting when the call fails."""
cmd = ["tea", "api", "--login", login]
if method != "GET":
cmd += ["-X", method]
if payload is not None:
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
with open(path, "w") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
cmd += ["-d", "@" + path]
cmd.append(endpoint)
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
if allow_fail:
return None
die("`tea api %s %s` failed:\n%s" % (method, endpoint, (r.stderr or r.stdout).strip()))
body = r.stdout.strip()
if not body:
return None
try:
return json.loads(body)
except json.JSONDecodeError:
if allow_fail:
return None
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
def pages(login, endpoint, limit=50, max_pages=40, **kw):
"""GET a list endpoint page by page, yielding each page as it arrives.
A generator, because a caller whose budget is spent on what it *keeps*
cannot be served by a function that fetches everything first: the page after
the one that completed the budget must never be requested. Stop consuming
and no further request is made."""
sep = "&" if "?" in endpoint else "?"
for page in range(1, max_pages + 1):
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
if not isinstance(batch, list) or not batch:
return
yield batch
if len(batch) < limit:
return # a short page is the last one
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
"""GET a list endpoint page by page; return the concatenated list."""
out = []
for batch in pages(login, endpoint, limit=limit, max_pages=max_pages, **kw):
out.extend(batch)
return out
def repo_base(repo=None):
"""API prefix. Without --repo, let tea fill {owner}/{repo} from CWD."""
return "repos/%s" % repo if repo else "repos/{owner}/{repo}"
def repo_slug(login, repo=None):
"""owner/repo as a literal string — needed for remote keys, which must not
contain tea's {owner}/{repo} placeholder."""
if repo:
return repo
got = api(login, "repos/{owner}/{repo}", allow_fail=True)
if isinstance(got, dict) and got.get("full_name"):
return got["full_name"]
die("cannot determine owner/repo from the CWD — pass --repo owner/repo")
def parse_key(key):
"""Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / a URL."""
key = key.strip()
m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key)
if m:
return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2))
m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key)
if m:
return int(m.group(2)), m.group(1)
m = re.match(r'^#?(\d+)$', key)
if m:
return int(m.group(1)), None
die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key)
# --------------------------------------------------------------------------
# filters
# --------------------------------------------------------------------------
def resolve_milestone(login, base, value):
"""(id, title) for a milestone given by id or title. Exits if unknown.
Gitea silently IGNORES an unresolvable `milestones=` filter and returns the
whole backlog, so the milestone must be resolved before it is trusted."""
got = paginate(login, "%s/milestones?state=all" % base, limit=100)
for m in got or []:
if str(m.get("id")) == str(value) or m.get("title") == str(value):
return m["id"], m.get("title", "")
have = ", ".join("%s (id %d)" % (m.get("title", ""), m["id"]) for m in got or [])
die("no milestone %r in this repo — have: %s" % (value, have or "none"))
def matches(payload, milestone_id=None, labels=()):
"""Client-side re-check of a server-side filter — see resolve_milestone."""
if payload.get("pull_request"):
return False
if milestone_id is not None and (payload.get("milestone") or {}).get("id") != milestone_id:
return False
names = {l.get("name", "") for l in payload.get("labels") or []}
return all(l in names for l in labels)
def list_issues(login, base, state="open", labels=(), query=None,
milestone=None, limit=100, keep=None):
"""Filtered issue payloads. Returns (payloads, milestone_title).
One request per page, and the payload already carries the issue bodies — a
whole milestone costs one call per 50 issues, not one per issue.
`limit` counts the payloads the CALLER cares about, not the ones the server
returned. Without `keep` those are the same thing and this behaves as it
always did. With it, `keep(payload)` says whether a payload counts, pages
keep coming until `limit` of them have, and the returned list carries the
ones that did not count too — they were enumerated, and a caller that has
something to say about them (pull.py: "N closed, not stored") still can.
What `keep` means is the caller's business; this module only counts. Two
boundaries hold whatever it decides:
- **Stop at the limit.** The page after the one that completed the budget
is not requested — `pages` is a generator and this loop returns out of it.
- **Stop at the page budget.** A predicate that rejects everything must not
turn a bounded read into a walk of the whole tracker, so a filtered read
may scan at most `PAGE_SLACK` times the pages `limit` would need if every
payload counted. Hitting that with an unfilled budget is a warning, not a
silent short answer: the caller asked for N and is told it got fewer."""
if limit < 1:
die("--limit must be 1 or more, got %d" % limit)
ms_id, ms_title = (None, None)
if milestone is not None:
ms_id, ms_title = resolve_milestone(login, base, milestone)
params = {"state": state, "type": "issues"}
if labels:
params["labels"] = ",".join(labels)
if query:
params["q"] = query
if ms_title:
params["milestones"] = ms_title
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
per_page = min(limit, 50)
ideal = max(1, -(-limit // per_page))
budget = ideal if keep is None else ideal * PAGE_SLACK
got, kept, seen_pages, last_full = [], 0, 0, False
for batch in pages(login, endpoint, limit=per_page, max_pages=budget):
seen_pages += 1
last_full = len(batch) == per_page
for p in batch:
if not matches(p, ms_id, labels):
continue
got.append(p)
if keep is None or keep(p):
kept += 1
if kept >= limit:
return got, ms_title
if keep is not None and seen_pages >= budget and last_full:
warn("scanned %d page(s) and stopped %d short of --limit %d — there may"
" be more; narrow the filter or raise --limit" % (budget, limit - kept, limit))
return got, ms_title
def get_issue(login, base, number):
payload = api(login, "%s/issues/%d" % (base, number))
if not isinstance(payload, dict) or "number" not in payload:
die("issue #%d not found" % number)
return payload
def get_comments(login, base, number):
return paginate(login, "%s/issues/%d/comments" % (base, number))
def native_deps(login, base, number):
"""Gitea's own issue-dependency links; empty when unsupported."""
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
return [i["number"] for i in got] if isinstance(got, list) else []
def native_dep_pairs(login, base, number):
"""The same links as {(owner/repo, number)} — what a repeat push compares
against so it does not POST a link the tracker already has.
A bare number is ambiguous the moment a dependency lives in another repo,
and IssueMeta lets it, so the repo travels with it. The pair is a transport
fact; formatting it as `owner/repo#42` is map.py's job, not this module's."""
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
out = set()
for i in got if isinstance(got, list) else []:
repo = (i.get("repository") or {}).get("full_name") or ""
if "number" in i:
out.add((repo, int(i["number"])))
return out
def add_dependency(login, base, number, dep_repo, dep_number):
"""Make issue `number` depend on `dep_repo#dep_number`. True on success.
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
POST /repos/{owner}/{repo}/issues/{index}/dependencies
body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
"Make the issue in the url depend on the issue in the form."
The URL names the blocked issue and the body the blocker, which is the same
direction native_deps reads back ("all issues that block this issue"). A
link that already exists answers 409, so a failure here is reported and not
fatal: one missing cross-link must not abort a push that has already
created issues. Callers pre-filter with native_dep_pairs."""
owner, _, name = (dep_repo or "").partition("/")
if not owner or not name:
return False
payload = {"index": int(dep_number), "owner": owner, "repo": name}
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
return got is not None
# --------------------------------------------------------------------------
# labels
# --------------------------------------------------------------------------
def ensure_labels(login, base, specs, root):
"""Map label name -> id, creating what the repo is missing.
`specs` is {name: {"color", "description", "exclusive"}} handed in by the
caller — this module does not know which namespaces are exclusive or what
they mean. Cached in <root>/.labels.json; the cache is refreshed from the
API before anything is created."""
cache_path = os.path.join(root, ".labels.json")
cache = {}
if os.path.isfile(cache_path):
try:
with open(cache_path) as f:
cache = json.load(f)
except Exception:
cache = {}
if any(n not in cache for n in specs):
cache = {l["name"]: l["id"] for l in paginate(login, "%s/labels" % base, limit=100)}
for name, spec in specs.items():
if name in cache:
continue
payload = dict(spec, name=name)
created = api(login, "%s/labels" % base, "POST", payload,
payload_name="label-%s" % name.replace("/", "-"))
if not created or "id" not in created:
die("could not create label %r" % name)
cache[name] = created["id"]
sys.stderr.write("created label %s%s\n"
% (name, " (exclusive)" if spec.get("exclusive") else ""))
os.makedirs(root, exist_ok=True)
with open(cache_path, "w") as f:
json.dump(cache, f, indent=2, sort_keys=True)
return {n: cache[n] for n in specs}
def resolve_milestone_id(login, base, title):
"""Milestone id for a title, or None when the repo has no such milestone."""
if not title or title == "none":
return None
for m in paginate(login, "%s/milestones?state=all" % base, limit=100) or []:
if m.get("title") == title:
return m["id"]
return None
# --------------------------------------------------------------------------
# store-side files this layer owns
# --------------------------------------------------------------------------
# The issue file itself is the domain's (`issue.path_of`). The one file the sync
# layer puts beside it is named here, in one place, because three commands have
# to agree on it: pull.py writes the thread, comment.py refetches it, push.py
# deletes it along with the issue it just sent.
def comments_path(root, id):
"""An issue's comment thread — beside it, under the same slug.
A path, not a concept the domain needs: a thread is pulled from Gitea and
never pushed back, so the domain has no reason to know the file exists."""
return os.path.join(root, "%s.comments.md" % id)
# --------------------------------------------------------------------------
# id map: remote key <-> local slug
# --------------------------------------------------------------------------
def map_path(root):
return os.path.join(root, REMOTE_MAP)
def load_map(root):
"""{"owner/repo#42": "wire-sqlc-appclick"} — the local slug ledger.
Entries outlive the files they name, and that is now the normal case rather
than a leak: `push.py` deletes an issue's file the moment Gitea confirms it,
and the entry it leaves behind is what lets the next `pull.py 42` land on
the same slug. Nothing prunes them, because "no file" no longer means "no
such issue". A stale entry costs one json line and is corrected the next
time that number is pulled."""
p = map_path(root)
if not os.path.isfile(p):
return {}
try:
with open(p) as f:
got = json.load(f)
return got if isinstance(got, dict) else {}
except Exception:
return {}
def save_map(root, m):
os.makedirs(root, exist_ok=True)
with open(map_path(root), "w") as f:
json.dump(m, f, indent=2, sort_keys=True)
def rebuild_map(root, issues):
"""Fold the `gitea:` fields still on disk into the id map. Returns it.
This used to say "the files are the source of truth; .remote.json is only an
index over them", and that stopped being true the day push started deleting
the file it had just sent. A pushed issue leaves no `gitea:` field behind to
read, so the files are now a SUBSET of what the map knows, and a rebuild
from them alone would throw away every entry it cannot see.
So the contradiction is resolved by moving the source of truth, not by
keeping this function honest about files:
Gitea the issue, and — in `<!-- tea:id … -->` — its slug
.remote.json a local number -> slug ledger, a cache of that marker
tmp/issues/*.md whatever happens to be checked out right now
Which makes this a MERGE and never a replacement: it starts from what is
already recorded and adds what the remaining files say. What it cannot
recover — a pushed-and-dropped issue whose ledger entry was also lost — is
not lost either; the next `pull.py <n>` reads the slug off the marker and
writes the entry back."""
m = load_map(root)
for id, iss in issues.items():
key = iss.extra.get("gitea")
if key:
m[key] = id
save_map(root, m)
return m
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""
close.py — change an issue's state in Gitea, and in the local copy with it.
The one regular tracker operation that used to have no script: closing. Without
it the only way to move `state:` was a raw `tea api -X PATCH -d '{"state":
"closed"}' repos/OWNER/REPO/issues/N`, which spells out the owner, the repo and
the request body — the three things `_gitea.py` exists to hide — and which needs
`Bash(tea api *)`, a permission that also covers `-X DELETE` on the repository.
close.py wire-sqlc-appclick one issue, by slug
close.py wire-sqlc-appclick 42 #43 several, by slug or number
close.py --reopen 42 the same thing backwards
close.py --dry-run 42 43 what would happen, no request at all
STATE ONLY. This script sends `{"state": …}` and nothing else: no title, no
body, no labels, no milestone. Editing an issue is `pull.py` -> edit ->
`push.py --update`; closing it is not an edit.
**What may be named.** A local slug, or a Gitea key (`42`, `#42`,
`owner/repo#42`, an issue URL) — the same forms `pull.py` takes. Both are
needed, and for the same reason: a push deletes the local file, so most issues
in the tracker have no slug on disk to name them by. A slug is resolved through
the file's `gitea:` field when the file is there, and through the ledger
(`.remote.json`) when push has already dropped it.
**An `origin: local` issue cannot be closed.** It is not in the tracker, so
there is nothing to close there, and the run stops naming the id rather than
quietly editing one field of a local file. Delete it, or push it first.
**Explicit ids only.** No `--milestone`, no `--label`, no "close everything
that looks done". Which issues are finished is a judgement about content; this
script only carries it out, one named id at a time. Nothing here deletes an
issue either — Gitea can, and it is not an operation of this workflow.
The local file is written only after the tracker has confirmed the write:
1. `tea` ran and exited 0 (a non-2xx exits the run inside `_gitea.api`), and
2. the answer is an object carrying the very number that was PATCHed, and
3. its `state` is the state we asked for.
Anything else and the file is left exactly as it was — see `confirmed`. An
issue whose local copy is gone (pushed and dropped) is closed in Gitea and
nothing is written; the state comes down with the next `pull.py`.
Gitea refuses to close an issue that its own dependency graph still blocks. That
refusal arrives as a non-2xx and stops the run with the tracker's own words:
close the blockers first, or unlink them in the web UI.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import re
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import issue_index # noqa: E402
import map as gmap # noqa: E402
# What `_gitea.parse_key` accepts, asked as a question instead of an assertion:
# parse_key exits on anything it cannot read, and here "not a key" is the normal
# case — it means the argument is a slug. A slug never contains `#`, `/` or `:`,
# so the two vocabularies cannot collide.
KEY_RE = re.compile(r'^(#?\d+|[\w.-]+/[\w.-]+#\d+|https?://\S+)$')
def looks_like_key(arg):
return bool(KEY_RE.match((arg or "").strip()))
def ledger_pairs(remote_map, repo=None):
"""[(repo, number, slug)] from `.remote.json`, filtered to `repo`.
A `--repo` that was not given means "whatever the ledger holds": resolving
the repo's real name costs a request, and a dry run is required to make
none. The ambiguity that opens — one number under two repos — is caught at
lookup time rather than papered over."""
out = []
for key, slug in sorted(remote_map.items()):
r, n = gmap.parse_remote_key(key)
if n:
if repo is None or r == repo:
out.append((r, n, slug))
return out
def one(candidates, what, arg):
"""The single `(repo, value)` in `candidates`, None when empty, or exit.
Two answers mean the ledger knows this number (or this slug) under more than
one repository, and only `--repo` can settle that."""
got = sorted(set(candidates))
if len(got) > 1:
_gitea.die("%r matches %s under more than one repo (%s) — pass "
"--repo owner/repo" % (arg, what, ", ".join(r for r, _v in got)))
return got[0] if got else None
def resolve(arg, issues, pairs):
"""(id, number, repo) for one argument. Either of `id` and `repo` is None
when nothing this machine holds names it.
Order, and it is the order of what is most authoritative about this machine:
a file on disk, then the ledger, then nothing. A key skips straight to the
ledger — its number is already the tracker's answer, and the slug is only
wanted so the local copy, if there is one, can be kept honest.
`repo` travels out with the number because a key may name one
(`owner/repo#42`) and a `gitea:` field always does. Sending a foreign key to
whatever repo the CWD happens to be in would close somebody else's issue of
the same number, so the caller reconciles them before anything goes out."""
if looks_like_key(arg):
number, repo = _gitea.parse_key(arg)
hit = one([(r, s) for r, n, s in pairs
if n == number and (repo is None or r == repo)], "a slug", arg)
return (hit[1] if hit else None), number, repo or (hit[0] if hit else None)
iss = issues.get(arg)
if iss is not None:
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
if not number:
_gitea.die("%s is not in the tracker (origin: %s, no gitea: field) — "
"there is no state there to change; push.py %s first"
% (arg, iss.origin, arg))
return arg, number, repo
hit = one([(r, n) for r, n, s in pairs if s == arg], "a number", arg)
if hit:
return arg, hit[1], hit[0] # pushed, and its file went with the push
_gitea.die("no issue %r in the store or the ledger — pass a Gitea number "
"(42, #42, owner/repo#42, a URL) to close one this machine has "
"never seen" % arg)
def confirmed(got, number, state):
"""True when the tracker's answer confirms THIS write, and nothing else.
The gate in front of the local write, and deliberately boring: an answer
counts only when it is an object carrying the very number that was PATCHed
(`bool` rejected explicitly — `True` is an `int`) and the state that was
asked for. A non-2xx and a `tea` that would not run never reach here at all;
`_gitea.api` exits on both, so the file survives those by never being
written."""
if not isinstance(got, dict):
return False
n = got.get("number")
if isinstance(n, bool) or not isinstance(n, int) or n != number:
return False
return got.get("state") == state
def apply_state(root, iss, state, got):
"""Write the confirmed state onto the local file; return its path.
`state:` is the domain's own field, so it is set on the issue and written
out by the domain's own writer. The sync-owned freshness fields travel with
it: the answer that authorized this write is also the newest thing the
tracker has said about the issue, so `synced:` and `remote-updated:` are
stamped from it rather than left describing an older read."""
iss.state = state
iss.extra["synced"] = _gitea.now_iso()
if got.get("updated_at"):
iss.extra["remote-updated"] = got["updated_at"]
return issue.save(root, iss)
def main():
ap = argparse.ArgumentParser(description="Close (or reopen) issues in Gitea")
ap.add_argument("ids", nargs="+",
help="local ids, or Gitea keys: 42, #42, owner/repo#42, URL")
ap.add_argument("--reopen", action="store_true",
help="set the state back to open instead of closed")
ap.add_argument("--dry-run", action="store_true",
help="print what would change; makes no request at all")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
root = args.out
state = "open" if args.reopen else "closed"
verb = "reopen" if args.reopen else "close"
past = "reopened" if args.reopen else "closed"
# A store that is not there is not an error here: a number needs no local
# file, and closing an issue whose copy was dropped by push is the normal
# case. `load_all` reads an absent directory as an empty one.
issues = issue.load_all(root)
pairs = ledger_pairs(_gitea.load_map(root), args.repo)
# Every argument is resolved before anything is sent, so a typo in the third
# id does not leave the first two closed.
targets = []
for arg in args.ids:
got = resolve(arg, issues, pairs)
if got not in targets:
targets.append(got)
# One run, one repo. An explicit --repo is the operator's word and wins;
# without one, the repo comes from what the ids themselves said, and two
# answers are a question rather than a guess — `repo_base` would otherwise
# let `tea` fill the blank from the CWD and close the wrong #42.
named = {r for _i, _n, r in targets if r}
if not args.repo and len(named) > 1:
_gitea.die("all ids must belong to one repo, got: %s" % ", ".join(sorted(named)))
repo_arg = args.repo or (sorted(named)[0] if named else None)
if args.dry_run:
for id, number, _repo in targets:
iss = issues.get(id)
where = ("%s (state: %s)" % (issue.path_of(root, id), iss.state)
if iss is not None else "no local copy")
print("would %s %s #%d%s" % (verb, id or "?", number, where))
print("%d issue(s) would be %s; no request was made"
% (len(targets), past))
return
login = _gitea.require_login()
base = _gitea.repo_base(repo_arg)
touched = 0
for id, number, _repo in targets:
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH",
{"state": state}, payload_name="state-%d" % number)
# The gate. Above it nothing local has been written; below it the file
# is about to say something the tracker had better agree with.
if not confirmed(got, number, state):
_gitea.die("#%d: %s failed — the tracker's answer does not confirm the "
"write (%.200r). Nothing local was changed."
% (number, verb, got))
print("%s %s #%d %s" % (past, id or "?", number,
got.get("html_url", "")))
iss = issues.get(id)
if iss is None:
print(" no local copy — pull.py %d to get one" % number)
continue
print(" state: %s %s" % (state, apply_state(root, iss, state, got)))
touched += 1
if touched:
path, n = issue_index.build(root)
print("index: %s%d issue(s)" % (path, n))
if __name__ == "__main__":
main()
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
comment.py — post or edit a comment on a synced issue.
The last issue operation that used to be hand-rolled (`mkdir tmp/comment`,
`jq -Rs`, `tea api -X POST`). Entity commands like `tea comment` hang on a
multi-line body — an empty-looking positional triggers the $EDITOR fallback on
a TTY that does not exist — so everything goes through `tea api` with the
payload written to a file first.
comment.py wire-sqlc-appclick --file notes.md
comment.py wire-sqlc-appclick --body "готово, задеплоено"
comment.py wire-sqlc-appclick --file fix.md --edit 1234
The target is a local id, not a number: this layer resolves it through the
`gitea:` field. A local-only issue cannot be commented on — there is nothing to
comment on yet. After a successful write the comment thread is refetched into
<id>.comments.md so the local copy is not stale.
Comments are pull-only in the store: nothing round-trips them back, and editing
<id>.comments.md by hand changes nothing in Gitea.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
def main():
ap = argparse.ArgumentParser(description="Comment on a synced issue")
ap.add_argument("id", help="local issue id (must already be in Gitea)")
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--file", help="markdown file holding the comment body")
src.add_argument("--body", help="comment body inline (short, single-line)")
ap.add_argument("--edit", type=int, metavar="COMMENT_ID",
help="PATCH an existing comment instead of posting a new one")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
root = args.out
if not issue.store_exists(root):
_gitea.die("store %s does not exist — nothing was created" % root)
if not os.path.isfile(issue.path_of(root, args.id)):
_gitea.die("no issue %r in %s" % (args.id, root))
iss = issue.load(root, args.id)
number = gmap.number_of(iss)
if not number:
_gitea.die("%s is local-only (no gitea: field) — push it first" % args.id)
if args.file:
if not os.path.isfile(args.file):
_gitea.die("no such file: %s" % args.file)
with open(args.file) as f:
body = f.read().strip()
else:
body = args.body.strip()
if not body:
_gitea.die("empty comment body")
login = _gitea.require_login()
base = _gitea.repo_base(args.repo)
if args.edit:
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
{"body": body}, payload_name="comment-%d" % args.edit)
verb = "edited"
else:
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
{"body": body}, payload_name="comment-%s" % args.id)
verb = "posted"
if not isinstance(got, dict) or "id" not in got:
_gitea.die("%s failed, unexpected response" % verb)
comments = _gitea.get_comments(login, base, number)
cpath = os.path.join(root, "%s.comments.md" % args.id)
if comments:
with open(cpath, "w") as f:
f.write(gmap.render_comments(comments))
elif os.path.isfile(cpath):
os.remove(cpath)
print("%s comment %s on %s (#%d) %s"
% (verb, got["id"], args.id, number, got.get("html_url", "")))
print("thread: %s (%d comment(s))" % (cpath, len(comments)))
if __name__ == "__main__":
main()
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
evict.py — ask Gitea which stored issues are closed, then evict those.
evict.py check every synced issue in the store, evict the
ones Gitea says are closed
evict.py old-thing … only these
evict.py --dry-run ask, report, change nothing
The offline command is `/tea:issue`'s `issue_evict.py`, and it is the one that
decides and deletes — this script adds exactly one thing in front of it: a
`state:` that is not stale. A local `state:` is only as fresh as the last pull,
so an issue closed in the web UI an hour ago still reads `open` here and the
offline command will (correctly) leave it alone. That is the gap this closes,
and it is the observed workflow: before this existed the operator had to
`pull.py 11 12 13 14 15` first, which re-wrote the five closed files onto disk
before anything could remove them.
Order of operations, and it is the whole safety argument:
1. every candidate's state is fetched — ALL of them, before anything is
removed;
2. each answer must be an object carrying the number we asked about and a
state from the domain's own vocabulary (`confirmed_state`);
3. only then is the eviction run, by handing the refreshed issues to
`issue_evict.run` — the same decision, the same deletion, the same
protection of `origin: local`, in one place.
A `tea` that will not run, a non-2xx, an answer for another issue, a state
nobody recognizes: the run stops at step 2 and NOTHING is deleted, not even the
issues whose answers had already arrived. That is stricter than `push.py`, which
deletes as it goes, and it costs nothing here — there is no ordering constraint
between evictions, so there is no reason to start before every answer is in.
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
none, is never asked about, and is never evicted — it is not in the tracker to
be closed. An `origin: gitea` issue whose handle is missing or unparseable
cannot be verified, so it is reported and kept rather than guessed at.
Cost: one GET per candidate. The store is a working set that push keeps small,
and a wrong answer here deletes a file, so each issue is asked about by its own
address rather than inferred from a list that a `--limit` could have truncated.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import issue_evict # noqa: E402
import map as gmap # noqa: E402
def candidates(issues, ids=None):
"""(checkable, unverifiable) — which issues the tracker can be asked about.
checkable is [(id, repo, number)] read off the `gitea:` handle, so an issue
that lives in another repo is asked about there. unverifiable is
[(id, why)]: it names a tracker but carries no handle to reach it by, which
is a file to report, never one to delete on a guess.
An `origin: local` issue is in neither list. It has no handle because it has
never left this machine, and asking Gitea about it is not a question that
has an answer.
"""
checkable, unverifiable = [], []
for id in (list(ids) if ids else sorted(issues)):
iss = issues[id]
if iss.is_local:
continue
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
if not repo or not number:
unverifiable.append((id, "origin: %s but no usable `gitea:` handle"
% iss.origin))
continue
checkable.append((id, repo, number))
return checkable, unverifiable
def confirmed_state(got, number):
"""The state Gitea confirmed for `number`, or None — the deletion gate.
The counterpart of `push.confirmed_number`, and written the same way: boring,
and saying no by default, because everything downstream of a `str` return
here may delete a file. An answer counts only when it is a dict, carries the
very number we asked about, and names a state the domain recognizes.
`bool` is rejected explicitly: `True` is an `int` in Python, and an answer
about issue `true` is not an answer about issue 42.
What it does not have to catch, because it never gets here: a non-2xx or a
`tea` that would not run at all — `_gitea.api` exits on both.
"""
if not isinstance(got, dict):
return None
n = got.get("number")
if isinstance(n, bool) or not isinstance(n, int) or n != number:
return None
state = got.get("state")
return state if state in issue.STATES else None
def main(argv=None):
ap = argparse.ArgumentParser(
description="Evict issues Gitea reports as closed from the local store")
ap.add_argument("ids", nargs="*",
help="issue ids (default: every synced issue in the store)")
ap.add_argument("--dry-run", action="store_true",
help="ask the tracker and report; write and delete nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args(argv)
root = args.out
if not issue.store_exists(root):
_gitea.die("store %s does not exist — nothing to evict" % root)
issues = issue.load_all(root)
missing = [i for i in args.ids if i not in issues]
if missing:
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
checkable, unverifiable = candidates(issues, args.ids)
for id, why in unverifiable:
_gitea.warn("%s: %s — kept, and not asked about" % (id, why))
if not checkable:
print("nothing to check: no issue in the store carries a `gitea:` handle")
return 0
login = _gitea.require_login()
# ---- every answer first, deletions after -----------------------------
fresh = {}
for id, repo, number in checkable:
got = _gitea.api(login, "%s/issues/%d" % (_gitea.repo_base(repo), number))
state = confirmed_state(got, number)
if state is None:
_gitea.die("%s: the tracker's answer for %s#%d does not confirm a state "
"(%.200r). Nothing was evicted."
% (id, repo, number, got))
fresh[id] = state
# The store stops lying even about the issues that stay: an answer already
# paid for is written back when it disagrees with the file. This is the only
# write this script makes, and a dry run makes none.
for id, state in sorted(fresh.items()):
was = issues[id].state
if was == state:
continue
print("state %s %s -> %s" % (id, was, state))
issues[id].state = state
if not args.dry_run:
issue.save(root, issues[id])
issue_evict.run(root, issues, [id for id, _, _ in checkable], args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
labels.py — put the canonical label set into a repository, in one run.
Every `type/*` and every `severity/*` the domain taxonomy defines, created up
front instead of trickling in as a side effect of whichever push first happens
to use one. Until a name exists in the repository nobody can filter by it in
the web UI, so somebody makes their own with a foreign color and without
`exclusive`, and the set arrives in pieces over months.
labels.py --dry-run print the plan, write nothing
labels.py create whatever is missing
labels.py --fix also patch color / `exclusive` drift
labels.py --repo owner/repo outside the repository's own checkout
No label name is spelled out in this file. The names are assembled from the
domain — issue.TYPES, issue.SEVERITIES, issue.EXCLUSIVE_NS — and painted by
map.label_specs; add a type over in skills/issue and the next run creates it.
`tea labels create` cannot set `exclusive` (tea 0.14.2), so creation goes
through `tea api`.
The repository's own labels are read before anything is written. A name that
matches exactly is left alone — never re-created, never patched; a color or
`exclusive` that disagrees with the spec is reported, and corrected only under
--fix. A name that merely RESEMBLES a canonical one (the same tail, up to
case, separator and whatever namespace is in front: `X`, `x`, `kind/x`,
`type: x` against `type/x`) is reported with its id and never touched —
renaming somebody else's label is a decision, not a step.
Out of scope by design: `tech/*` and `comp/*`, which are open-ended and get
created by push as they come up, and deleting or renaming anything at all.
Only repository labels are read; an organization's own labels sit behind a
different endpoint and are neither read nor written.
The issue store is out of scope too, and not incidentally. A label belongs to
the repository, not to any issue, so this command neither reads tmp/issues/ nor
creates it — the taxonomy it paints comes from the domain MODULE, and the
request bodies it sends go to the transport's own tmp/payload/.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import re
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
# --------------------------------------------------------------------------
# the canonical set
# --------------------------------------------------------------------------
# Which taxonomy collection fills which exclusive namespace. Both sides are the
# domain's — this dict is only the join between them, and it is the whole
# reason no name has to be repeated here.
MEMBERS = {"type/": issue.TYPES, "severity/": issue.SEVERITIES}
def canonical_names():
"""Every name in the canonical set, in taxonomy order.
Which namespaces are exclusive is issue.EXCLUSIVE_NS; what lives in each
is MEMBERS, i.e. the domain again. A namespace the domain declares but
MEMBERS does not know about is handed back separately — better reported
than quietly missing from the set."""
names, orphan = [], []
for ns in issue.EXCLUSIVE_NS:
if ns in MEMBERS:
names += [ns + m for m in MEMBERS[ns]]
else:
orphan.append(ns)
return names, orphan
# --------------------------------------------------------------------------
# lookalikes
# --------------------------------------------------------------------------
WORDS = re.compile(r'[^a-z0-9]+')
def akin(name):
"""Comparison keys for a label name: its tail, and the whole name squashed.
Case, separators and the namespace in front are noise — what a person
meant is the tail. `x`, `X`, `kind/x` all reduce to the same tail as
`type/x`, and `severity: x y` to the same squashed form as `severity/xy`.
Two names resemble each other when these sets intersect."""
parts = [p for p in WORDS.split(name.lower()) if p]
return {parts[-1], "".join(parts)} if parts else set()
# --------------------------------------------------------------------------
# plan
# --------------------------------------------------------------------------
def color_of(value):
"""Gitea reports colors bare, map.py writes them with a `#`. Same color."""
return (value or "").lstrip("#").lower()
def drift_of(spec, got):
"""Where an existing label disagrees with the spec, as (field, is, want).
Only color and `exclusive` — a description somebody rewrote is theirs, and
the name matched exactly or we would not be here."""
out = []
if color_of(got.get("color")) != color_of(spec.get("color")):
out.append(("color", color_of(got.get("color")), color_of(spec.get("color"))))
if bool(got.get("exclusive")) != bool(spec.get("exclusive")):
out.append(("exclusive", str(bool(got.get("exclusive"))).lower(),
str(bool(spec.get("exclusive"))).lower()))
return out
def plan(specs, existing):
"""(rows, similar) for one repository, decided before anything is written.
A row is (name, spec, got, drift), one per canonical label in taxonomy
order: `got` is the repository's own payload when that exact name is
already there (None when it is not), `drift` what disagrees with the spec.
`similar` is (name, id, [canonical it resembles]) for the repository's
other labels. They are reported and left alone: this script owns the
canonical names, not everything that looks like one."""
by_name = dict((l.get("name", ""), l) for l in existing or [])
rows = []
for name in specs:
got = by_name.get(name)
rows.append((name, specs[name], got, drift_of(specs[name], got) if got else []))
keys = dict((name, akin(name)) for name in specs)
similar = []
for l in existing or []:
name = l.get("name", "")
if name in specs:
continue
mine = akin(name)
hits = [n for n in specs if keys[n] & mine]
if hits:
similar.append((name, l.get("id"), hits))
return rows, similar
# --------------------------------------------------------------------------
# run
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="Create the canonical type/* and severity/* labels in a repository")
ap.add_argument("--dry-run", action="store_true",
help="print the plan; not one writing request")
ap.add_argument("--fix", action="store_true",
help="also patch color/exclusive on labels that already exist")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
args = ap.parse_args()
names, orphan = canonical_names()
for ns in orphan:
_gitea.warn("namespace %r is exclusive in the domain but has no members here "
"— nothing created for it" % ns)
specs = gmap.label_specs(names)
login = _gitea.require_login()
base = _gitea.repo_base(args.repo)
# Read first, always: the plan is decided against the repository itself,
# never against tmp/issues/.labels.json. That cache is what makes
# _gitea.ensure_labels cheap for push.py and wrong for a bootstrap — it
# answers "what did we create last time", and the answer here has to be
# "what does the repository have right now".
existing = _gitea.paginate(login, "%s/labels" % base, limit=100)
rows, similar = plan(specs, existing)
fixed, drifted = 0, 0
for name, spec, got, drift in rows:
mark = " exclusive" if spec.get("exclusive") else ""
if got is None:
if args.dry_run:
print("create %-20s %s%s" % (name, spec["color"], mark))
continue
payload = dict(spec, name=name)
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
payload_name="label-%s" % name.replace("/", "-"))
if not new or "id" not in new:
_gitea.die("could not create label %r" % name)
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
continue
if not drift:
print("present %-20s id %s" % (name, got.get("id")))
continue
drifted += 1
shown = ", ".join("%s %s -> %s" % d for d in drift)
if not args.fix:
print("present %-20s id %-5s drift: %s" % (name, got.get("id"), shown))
continue
if args.dry_run:
print("fix %-20s id %-5s %s" % (name, got.get("id"), shown))
continue
# Gitea 1.26 patches only the fields it is given, but the unchanged
# name and description ride along anyway: they cost nothing and an
# older server that reads an absent field as empty would blank them.
patch = {"name": name, "description": got.get("description") or ""}
for field, _is, _want in drift:
patch[field] = spec[field]
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
payload_name="label-%s" % name.replace("/", "-"))
fixed += 1
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
for name, id, hits in similar:
_gitea.warn("%r (id %s) resembles %s — left alone; rename it by hand or ignore it"
% (name, id, ", ".join(hits)))
missing = sum(1 for r in rows if r[2] is None)
print("%d canonical label(s): %d %s, %d present%s%s"
% (len(rows), missing, "to create" if args.dry_run else "created",
len(rows) - missing,
" (%d drifted, %d fixed)" % (drifted, fixed) if drifted else "",
", %d similar" % len(similar) if similar else ""))
if drifted and not args.fix:
print("drift is shown, not applied — re-run with --fix to patch color/exclusive")
if args.dry_run:
print("dry-run — nothing was written")
if __name__ == "__main__":
main()
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
map.py — md <-> Gitea JSON. The whole translation, and only the translation.
Pure functions: no network, no filesystem, no argparse. Give it a payload and
it hands back a domain Issue; give it an Issue and it hands back a request
body. That purity is the point — it can be reasoned about and tested without a
Gitea anywhere, and it is the single file to open when the two representations
disagree.
Direction of knowledge: this module imports the domain (issue.py) and is
imported by the transport's callers. The domain never imports this.
What crosses the boundary, and what does not:
domain Gitea note
----------------------------------------------------------------------
id (slug) body marker `<!-- tea:id … -->`, first line of
the tracker-side body; stripped out
of the local copy — see below
title title verbatim, both ways
body body verbatim up, verbatim down except
the marker and checkbox state — see
with_id_marker / merge_checkbox_state
state state open/closed, same vocabulary
labels labels[] names both ways; ids only on write
assignees assignees[] logins
milestone milestone.title resolved to an id on write
depends — slugs; #N is translated at the edge
— number, html_url lands in extra as gitea:/url:
— ref extra as branch:; push fills it from git
`depends:` is the authoritative graph and 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, and a push
never rewrites what the author wrote. Deliberate — a translator that edits
prose churns the body on every round trip.
The ONE thing this module does add to a body is the id marker, and it does so
because the slug now has to survive a push: `push.py` deletes the local file,
so the tracker has to remember what the issue was called here. See
`with_id_marker`.
"""
import os
import re
import sys
sys.path.insert(0, os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "issue", "scripts")))
import issue # noqa: E402
# How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
# an issue IS, which is exactly why it lives here and not in the domain.
LABEL_COLORS = {
"type/bug": "#ee0701",
"type/task": "#0e8a16",
"type/refactor": "#1d76db",
"type/test": "#fbca04",
"type/feature": "#5319e7",
"type/draft": "#cccccc",
"severity/low": "#c2e0c6",
"severity/medium": "#fbca04",
"severity/high": "#eb6420",
"severity/showstopper": "#ee0701",
"severity/critical": "#b60205",
}
DEFAULT_COLOR = "#ededed"
# What this bridge writes into the domain's `origin:` field. The domain records
# that an issue exists somewhere else; only this module knows where.
ORIGIN = "gitea"
# Metadata key for Gitea's `ref` — the branch an issue is pinned to. A sync
# field: its value is a git branch name and means exactly `ref`, so the domain
# carries it in `extra` and never reads it.
BRANCH_KEY = "branch"
def label_specs(names):
"""{name: {color, description, exclusive}} for the transport to create.
Exclusivity and meaning come from the domain taxonomy; only the color is
decided here. `tea labels create` cannot set `exclusive` (as of 0.14.2),
which is why these go through the API."""
out = {}
for name in names:
desc = ""
if name.startswith("type/"):
desc = issue.TYPES.get(name.split("/", 1)[1], "")
out[name] = {
"color": LABEL_COLORS.get(name, DEFAULT_COLOR),
"description": desc,
"exclusive": name.startswith(issue.EXCLUSIVE_NS),
}
return out
def remote_key(repo, number):
"""Stable cross-repo handle: owner/repo#42."""
return "%s#%d" % (repo, int(number))
def parse_remote_key(key):
repo, _, num = (key or "").rpartition("#")
return (repo, int(num)) if repo and num.isdigit() else (None, None)
# --------------------------------------------------------------------------
# the id marker: the slug, kept tracker-side
# --------------------------------------------------------------------------
# `push.py` deletes the local file once the tracker has confirmed the write, so
# the slug — the issue's ONLY identity in the domain — cannot live only on this
# machine any more. It rides up in the body as an HTML comment:
#
# <!-- tea:id wire-sqlc-appclick -->
#
# Why the body and not `.remote.json`: the map is a local file, and "the local
# copy is not the record" is the whole point of deleting it. A marker in the
# body survives a rename in the web UI, a lost `.remote.json`, a fresh clone,
# and a second machine — none of which the map does. Why an HTML comment: Gitea
# renders markdown, so it is invisible to a human reader, and it comes back
# verbatim on every API read.
#
# WHERE: the first line of the tracker-side body, followed by one blank line.
# First because it is the one position that does not depend on what sections the
# issue happens to have, and because a human who does look at the raw markdown
# finds it before the prose rather than buried in it.
#
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
# and the slug is already the file's name, so a copy of it in the body would be
# duplicated state.
#
# WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
# strip-all-then-prepend-one. `with_id_marker` never appends to what is there,
# and `strip_id_marker` removes EVERY marker line, not the first. So a body that
# somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on the
# next pull and goes back up with exactly one. There is no code path that adds
# a marker to a body that has not just been stripped.
_MARKER_LINE = re.compile(r'^[ \t]*<!--[ \t]*tea:id[ \t]+(\S+)[ \t]*-->[ \t]*$')
def id_marker(id):
"""The marker line for a slug. One place formats it, one regex reads it."""
return "<!-- tea:id %s -->" % id
def id_in_body(body):
"""The slug a tracker-side body claims, or None.
The FIRST valid marker wins; a second one is ignored here and removed by
`strip_id_marker` on the way in. The captured text must be a slug by the
domain's own rule — a marker holding anything else is not a slug and is
treated as if it were not there, so a mangled comment falls back to the
title instead of naming a file after garbage."""
for line in (body or "").splitlines():
m = _MARKER_LINE.match(line)
if m and issue.SLUG_OK.match(m.group(1)):
return m.group(1)
return None
def strip_id_marker(body):
"""`body` with every marker line removed. Idempotent.
A body that carries no marker is returned byte for byte — the common case
(an issue filed in the web UI) costs nothing and is not reformatted. When a
marker is removed from the top, the blank line it was written with goes with
it, so the round trip is exact: strip(with_id_marker(b, id)) == b."""
text = body or ""
if not any(_MARKER_LINE.match(l) for l in text.splitlines()):
return text
kept = [l for l in text.splitlines() if not _MARKER_LINE.match(l)]
return "\n".join(kept).lstrip("\n")
def with_id_marker(body, id):
"""`body` with exactly one marker, as its first line.
Strip-then-prepend, always — that is the guarantee that a body can never end
up with two, however many it arrived with."""
return "%s\n\n%s" % (id_marker(id), strip_id_marker(body))
# --------------------------------------------------------------------------
# Gitea -> domain
# --------------------------------------------------------------------------
def numbers_in_body(body):
"""`#N` referenced from the body's dependency sections, as ints. Used only
to seed `depends:` on the first pull."""
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
def merge_checkbox_state(remote_body, local_body):
"""The remote body with every tick the local copy already had put back.
The one exception to "a pull overwrites the body", and it is deliberately
the narrowest one that works. A tick is **monotone** — an item only ever
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
reconciled: no base version, no drift tracking, no conflict to resolve. The
set is a set of item TEXTS, and an item comes out ticked when either side
has it ticked. Everything else in the body is still the remote's word.
Matching is on `Checkbox.text`, which the domain parser has already
stripped and rejoined with single spaces, so rewrapping a long item does
not cost it its tick. It is otherwise literal: reword an item and it is a
different item — the tick stays with the wording it was put on.
**The same text more than once** is read as the rule says, as a set: one
ticked local item ticks every remote item with that text. The alternative —
pairing duplicates up by order — is the reading that can still drop a tick
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
nothing), and dropping a tick is the bug this exists to fix. Two items
whose text is identical are the same item to whoever reads them.
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
pull, an empty store — returns the remote body untouched.
The price, accepted explicitly: UNticking is not monotone, so a box
unticked in the web UI comes back on the next pull. Untick locally, push.
"""
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
if not ticked:
return remote_body
body = remote_body
# set_checkbox trades one character for one character, so line numbers read
# off `remote_body` stay valid against the partially rewritten `body`.
for c in issue.checkboxes(remote_body):
if not c.checked and c.text in ticked:
body = issue.set_checkbox(body, c.line, True)
return body
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
local_body=None):
"""Build a domain Issue from a Gitea issue payload.
id_for_number maps a Gitea number to a local slug — dependencies whose
target has not been pulled yet are dropped from `depends:` (the body still
names them, so nothing is lost) rather than invented.
`local_body` is the body of the copy already in the store, when there is
one. It contributes exactly one thing: its ticked checkboxes survive the
overwrite (merge_checkbox_state). Pass None and the remote body is taken
whole, which is what a first pull does.
The id marker is stripped before anything else looks at the body: it is
transport bookkeeping, and the caller has already read the slug off it
(`pull.id_for`). Everything downstream — checkboxes, `#N` references, what
lands on disk — sees the body the author wrote."""
body = merge_checkbox_state(
strip_id_marker((payload.get("body") or "").strip()), local_body)
id_for_number = id_for_number or {}
numbers = list(numbers_in_body(body))
for n in extra_numbers:
if n not in numbers:
numbers.append(n)
depends, unresolved = [], []
for n in numbers:
slug = id_for_number.get(n)
if slug and slug != id and slug not in depends:
depends.append(slug)
elif not slug:
unresolved.append(n)
extra = {
"gitea": remote_key(repo, payload["number"]),
"url": payload.get("html_url", ""),
"synced": synced or "",
}
if payload.get("ref"):
extra[BRANCH_KEY] = payload["ref"]
if payload.get("updated_at"):
extra["remote-updated"] = payload["updated_at"]
if payload.get("comments"):
extra["comments"] = payload["comments"]
iss = issue.Issue(
id=id,
title=payload.get("title", ""),
body=body,
state=payload.get("state") or "open",
labels=[l.get("name", "") for l in payload.get("labels") or []],
assignees=[a.get("login", "") for a in payload.get("assignees") or []],
milestone=(payload.get("milestone") or {}).get("title") or "",
depends=depends,
origin=ORIGIN,
extra=extra)
return iss, unresolved
def render_comments(comments):
"""Comment thread as flat markdown. Read-only: nothing writes it back."""
out = []
for c in comments:
out.append("## comment %s%s%s" % (
c.get("id"), (c.get("user") or {}).get("login", ""),
(c.get("created_at") or "")[:10]))
out.append("")
out.append((c.get("body") or "(empty)").strip())
out.append("")
return "\n".join(out)
# --------------------------------------------------------------------------
# domain -> Gitea
# --------------------------------------------------------------------------
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
"""Request body for POST /issues or PATCH /issues/{n}.
The prose is sent verbatim — see the module docstring on why slugs in
`## Depends on` are not rewritten to `#N`. The one addition is the id
marker, prepended (never appended) so the tracker remembers the slug after
push has deleted the local file. `from_api` takes it straight back off, so
the body still round-trips byte for byte."""
payload = {"title": iss.title,
"body": with_id_marker(iss.body.strip(), iss.id)}
if label_ids is not None:
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
if iss.assignees:
payload["assignees"] = list(iss.assignees)
if milestone_id is not None:
payload["milestone"] = milestone_id
if include_state:
payload["state"] = iss.state
# An empty `branch:` is "no opinion", not "no branch": sending ref="" would
# clear whatever is set on the Gitea side, so the key is left out instead.
branch = (iss.extra.get(BRANCH_KEY) or "").strip()
if branch:
payload["ref"] = branch
return payload
def apply_remote(iss, payload, repo, synced):
"""Stamp the sync-owned fields onto an issue after a successful write.
Mutates and returns it; `origin` is the one domain field this touches."""
iss.origin = ORIGIN
iss.extra["gitea"] = remote_key(repo, payload["number"])
iss.extra["url"] = payload.get("html_url", "")
iss.extra["synced"] = synced
if payload.get("updated_at"):
iss.extra["remote-updated"] = payload["updated_at"]
return iss
def number_of(iss):
"""Gitea number for an already-synced issue, or None."""
_repo, n = parse_remote_key(iss.extra.get("gitea", ""))
return n
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""
pull.py — Gitea issues -> the local store.
Writes flat markdown the domain layer owns and prints a compact index; the raw
API payload never reaches the conversation.
**This is how you get a pushed issue back.** `push.py` deletes the local file
once Gitea has confirmed it, so pulling is not a refresh of 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 the web UI and even on a machine that has never seen the
issue: the slug travels in the body as `<!-- tea:id … -->`, and
tmp/issues/.remote.json indexes it by number. See `id_for` for the order those
are consulted in. The marker itself is stripped out of what is written to disk.
Two ways to name what to pull:
pull.py 42 [17 …] by key: 42 | #42 | owner/repo#42 | URL
pull.py --milestone 6 by filter: whole milestone in ONE request
pull.py --label type/bug --state all
pull.py -q sqlc --limit 20
Filter mode costs one request per 50 issues — the list payload already carries
the bodies. Gitea silently ignores an unresolvable `milestones=` filter and
returns the whole backlog, so the milestone is resolved up front and every
issue is re-checked locally. Projects are NOT filterable: the projects API is
not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI.
A closed issue is not a unit of work, so filter mode enumerates it but leaves
it out of the store: `--state all` still shows the whole picture, and only
`--state closed` writes one. An issue already on disk is refreshed either way,
so the local copy learns it was closed instead of staying open forever, and the
count of the ones left out goes to stderr. Key mode is exempt: an address is not
a bulk read, and `pull.py 1` fetches a closed issue as it always did.
**`--limit` is on the write, not on the selection.** It counts the issues this
run puts in the store — written, or left in place by `--cached` — and never the
closed ones it enumerated and threw away. `--limit 20` over a milestone whose
first 30 issues are closed still writes 20, if 20 open ones are there to write:
pages keep coming until the budget is full. Two boundaries keep that honest:
- Pages stop the moment the budget is full. Never one page more.
- A filtered read may scan at most `_gitea.PAGE_SLACK` times the pages the limit
would need if nothing were dropped. A filter that matches almost only closed
issues therefore ends in a warning and a short answer, not in a walk of the
whole tracker. Narrow the filter, or raise `--limit`, which raises the budget
with it.
- Dependencies are outside the count: a blocker is followed because a stored
issue named it, not because the filter selected it. `--limit 20` can
therefore leave more than 20 files behind — the budget counts the selection's
writes, and the graph is not part of the selection.
`remote.py` is the deliberate exception, and it is not the same flag twice: it
writes nothing at all, so there is no write to bound and its `--limit` means
what it says — how many lines to print.
Comments ride along by default, in both modes and for every issue written:
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
nothing when there is nothing to fetch — the payload already carries the
comment count, so an issue with none makes no request, and a file left over
from an earlier pull is deleted. An absent file therefore means "no comments",
never "not asked for". The thread is pull-only: editing it changes nothing in
Gitea (post with comment.py).
**Dependencies come with every pull.** A pull answers with the whole unit of
work — the issue and what blocks it — so `depends:` is filled from Gitea's
native dependency graph and every blocker is pulled too, recursively, down to
`--depth` (default 3). That graph is the only source there is: `map.from_api`
writes slugs into the `## Depends on` prose and never `#N`, so an edge cannot be
recovered from the body. `--no-deps` turns off both halves — no `depends:`, no
recursion, and no request spent on either. `--deps` is still accepted and now
does nothing; it names what already happens.
What it costs, stated rather than hidden:
- **One request per issue that lands in the store** — `GET …/issues/{n}/dependencies`,
fetched once and used twice, since the same links both fill `depends:` and
tell the walk where to go next. A closed issue that filter mode drops costs
nothing: nothing was stored, so there is no unit of work to complete.
- **One request per blocker the selection did not already carry** — a `GET` for
the issue itself, then its own links, and so on until `--depth`.
- So `--milestone X` over 50 open issues is one list request + 50 link requests
+ one pair for every blocker outside the milestone, where it used to be one
request flat. `--no-deps` is the way back to one.
**In filter mode a blocker the filter did not select still lands in the store,
and that is deliberate.** `--milestone X` can leave an issue from milestone Y on
disk and `--label` an unlabelled one: a blocker is followed because a stored
issue names it, not because it matched. The one blocker that does not land is a
closed one — closed is not a unit of work, filter mode drops it the way it drops
any other closed issue, and the `depends:` edge to it goes with it, so nothing
points at a file that is not there. Key mode has no such rule and stores it.
Other flags:
--no-deps do not fill depends:, do not follow blockers
--deps accepted, does nothing: it is the default now
--depth N how deep to follow blockers (default 3)
--cached skip issues already on disk (body AND comments)
--repo owner/repo default: auto-detect from the CWD git remote
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
have not pushed are lost — with exactly one exception, checkbox state. A `[x]`
on either side wins for any item whose text matches, because a tick is monotone
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
has the rule and its price). `--cached` skips an issue before any of that: it is
not read and not merged — it still costs its one link request, because a cached
issue's blockers can be missing from disk even when it is not (`--cached
--no-deps` is the free one). Draw the graph afterwards with the domain's own
issue_tree.py — it needs no network.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import issue_index # noqa: E402
import map as gmap # noqa: E402
def id_for(payload, store_ids, remote_map, repo, root):
"""The slug this remote issue belongs under. Three sources, in order.
1. **`.remote.json`, keyed by number.** The local ledger, and the only one
that knows about a file sitting on disk right now, so it wins. A
retitled issue keeps the slug it was first pulled under.
2. **The `<!-- tea:id … -->` marker in the body** (`gmap.id_in_body`). What
makes push -> delete -> pull a round trip rather than a rename: the
ledger can be lost (a fresh clone, another machine, a deleted
`.remote.json`) and the tracker still remembers what this issue is called
here — even after the title was changed in the web UI.
3. **The title, slugified.** Issues filed in the web UI have no marker and
have never had a local name; this is where they get one.
A marker is only taken at its word when the slug is free. If a file of that
name is already in the store, or the ledger has it under another number, the
marker is a collision and not an identity — the name is uniquified
(`marked-2`) rather than allowed to overwrite somebody else's issue."""
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
if got:
return got
marked = gmap.id_in_body(payload.get("body") or "")
if marked and marked not in store_ids and marked not in set(remote_map.values()):
return marked
return issue.unique_id(root, marked or issue.slugify(payload.get("title", "")),
taken=store_ids)
def lands_in_store(payload, drop_closed, store_ids, remote_map, repo, root):
"""Would this payload leave a file in the store? The `--limit` predicate.
It has to be the same test the walk below applies, or the budget is spent on
issues that never land — which is the bug this exists to prevent. So: a
closed issue counts only when the store already has it (it is refreshed, and
that is a write); anything else counts, including one `--cached` will skip,
because a skipped issue is still an issue the store holds when the run ends.
Cheap in the common case: only a closed payload costs an `id_for`, and that
is a lookup plus, at worst, a stat."""
if not (drop_closed and payload.get("state") == "closed"):
return True
id = id_for(payload, store_ids, remote_map, repo, root)
return os.path.isfile(issue.path_of(root, id))
def comments_path(root, id):
"""Where an issue's comment thread lives — beside it, under the same slug.
Named in `_gitea` because push.py has to delete the same file."""
return _gitea.comments_path(root, id)
def sync_comments(login, base, root, id, number, count):
"""Bring <id>.comments.md in line with the server; return it, or None when
the issue has no thread.
`count` is the payload's own comment count, so an issue with none costs no
request. A file from an earlier pull is removed when the thread is empty:
the absence of the file is the answer, not a gap in what was asked for."""
path = comments_path(root, id)
comments = _gitea.get_comments(login, base, number) if count else []
if comments:
with open(path, "w") as f:
f.write(gmap.render_comments(comments))
return path
if os.path.isfile(path):
os.remove(path) # stale thread from an earlier pull
return None
def main():
ap = argparse.ArgumentParser(description="Pull Gitea issues into the local store")
ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL")
ap.add_argument("--milestone", help="pull a whole milestone (id or title)")
ap.add_argument("--label", action="append", default=[],
help="filter by label; repeat for AND")
ap.add_argument("-q", "--query", help="search text in title/body")
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
help="filter mode only (default: open)")
ap.add_argument("--limit", type=int, default=100,
help="filter mode: how many issues to STORE, not to enumerate"
" (default: 100)")
# Dependencies are the default: a pull answers with the unit of work, not
# one row of it. `--deps` stays accepted so the calls and command tables
# written against the old default keep working — it now sets what is
# already set.
ap.add_argument("--no-deps", dest="deps", action="store_false",
help="do not fill depends: and do not follow blockers")
ap.add_argument("--deps", dest="deps", action="store_true",
help="accepted, does nothing: dependencies are followed by default")
ap.set_defaults(deps=True)
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
ap.add_argument("--cached", action="store_true",
help="skip issues already on disk instead of refetching")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
filtered = bool(args.milestone or args.label or args.query)
if args.keys and filtered:
_gitea.die("pass issue keys OR filters, not both")
if not args.keys and not filtered:
_gitea.die("nothing to pull: pass issue keys, or --milestone / --label / -q")
root = args.out
# A first pull into a fresh checkout has to create the store; it says so,
# and the path is absolute, so it cannot be a stray cwd.
if issue.create_store(root):
sys.stderr.write("created store %s\n" % os.path.abspath(root))
login = _gitea.require_login()
# ---- which repo ------------------------------------------------------
repo_arg = args.repo
if not repo_arg and args.keys:
repos = {_gitea.parse_key(k)[1] for k in args.keys} - {None}
if len(repos) > 1:
_gitea.die("all keys must belong to one repo, got: %s" % ", ".join(sorted(repos)))
repo_arg = repos.pop() if repos else None
base = _gitea.repo_base(repo_arg)
repo = _gitea.repo_slug(login, repo_arg)
issues = issue.load_all(root)
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
store_ids = set(issues)
number_of_id = {gmap.parse_remote_key(k)[1]: v for k, v in remote_map.items()
if gmap.parse_remote_key(k)[0] == repo}
# A closed issue is not a unit of work: filter mode enumerates it but keeps
# it out of the store unless the operator named the state. A key is an
# address, not a bulk read, so key mode is exempt.
drop_closed = filtered and args.state != "closed"
written, skipped, dropped, pending = [], [], [], []
# ---- seeds -----------------------------------------------------------
if filtered:
# The limit bounds the write, so the transport is told what a write is
# and counts those; the closed ones it enumerated on the way come back
# in the list anyway, to be reported and dropped below.
payloads, ms_title = _gitea.list_issues(
login, base, state=args.state, labels=args.label, query=args.query,
milestone=args.milestone, limit=args.limit,
keep=lambda p: lands_in_store(p, drop_closed, store_ids, remote_map,
repo, root))
if not payloads:
_gitea.die("no issues match that filter")
what = []
if args.milestone:
what.append("milestone %s" % ms_title)
what += ["label %s" % l for l in args.label]
if args.query:
what.append("q=%r" % args.query)
sys.stderr.write("%d issue(s) match %s (%s)\n"
% (len(payloads), " + ".join(what), args.state))
queue = [(p, 0) for p in payloads]
seen_numbers = {p["number"] for p in payloads}
else:
numbers = [_gitea.parse_key(k)[0] for k in args.keys]
queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers]
seen_numbers = set(numbers)
# ---- walk ------------------------------------------------------------
while queue:
payload, depth = queue.pop(0)
number = payload["number"]
id = id_for(payload, store_ids, remote_map, repo, root)
stored = os.path.isfile(issue.path_of(root, id))
# Closed and not already ours: nothing is written and nothing is asked
# of the server for it — not its comments, not its links, and its own
# blockers are not followed. The slug stays unclaimed too, so no other
# issue ends up pointing `depends:` at a missing file.
if drop_closed and payload.get("state") == "closed" and not stored:
dropped.append(number)
continue # not stored: no unit of work here, so no links are fetched
store_ids.add(id)
number_of_id[number] = id
# The native links, fetched ONCE for the two things they are for:
# filling this issue's `depends:` and telling the walk where to go next.
# One request per issue that lands in the store, and only one — the cost
# the docstring quotes is this line.
deps = _gitea.native_deps(login, base, number) if args.deps else []
if args.cached and stored:
skipped.append(id) # body and thread unread; only the links cost
else:
# The copy already on disk, as it was when this run started. It
# contributes its ticked checkboxes and nothing else; None when
# the store has never seen this issue.
prev = issues.get(id)
iss, unresolved = gmap.from_api(payload, id, repo,
id_for_number=number_of_id,
extra_numbers=deps,
synced=_gitea.now_iso(),
local_body=prev.body if prev else None)
issue.save(root, iss)
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
remote_map[gmap.remote_key(repo, number)] = id
written.append(id)
pending.append((id, unresolved))
if args.deps and depth < args.depth:
child_numbers = gmap.numbers_in_body(payload.get("body") or "") + deps
for n in child_numbers:
if n in seen_numbers:
continue
seen_numbers.add(n)
queue.append((_gitea.get_issue(login, base, n), depth + 1))
# Nothing is dropped in silence — say how many closed ones stayed out.
if dropped:
sys.stderr.write("%d closed issue(s) enumerated, not stored"
" (--state closed to pull them)\n" % len(dropped))
# ---- second pass: dependencies that were not yet known on first write --
for id, unresolved in pending:
newly = [number_of_id[n] for n in unresolved
if n in number_of_id and number_of_id[n] != id]
if not newly:
continue
iss = issue.load(root, id)
for slug in newly:
if slug not in iss.depends:
iss.depends.append(slug)
issue.save(root, iss)
_gitea.save_map(root, remote_map)
index_path, _ = issue_index.build(root)
# Compact output — the only thing that lands in the model's context. The
# thread rides on the issue's own line; no file means no comments.
graph = False
for id in sorted(set(written) | set(skipped)):
iss = issue.load(root, id)
graph = graph or bool(iss.depends)
note = " (cached)" if id in skipped else ""
cpath = comments_path(root, id)
if os.path.isfile(cpath):
note += " +%s comments: %s" % (iss.extra.get("comments") or "?", cpath)
print("%s [%s] %s%s %s%s" % (
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
issue.path_of(root, id), note))
print("index: %s" % index_path)
# Now that dependencies are the default, the hint is worth printing when
# there is something to draw, not on every run that could have drawn it.
if graph:
print("graph: run issue_tree.py (offline) to draw it")
if __name__ == "__main__":
main()
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""
push.py — local store -> Gitea, and the local copy goes away.
**A successful push deletes `tmp/issues/<id>.md` and `<id>.comments.md`.** Once
the tracker has the issue, the tracker IS the issue: what is left in the store
is only what has not left this machine. Get it back with `pull.py <n>` — it
comes back under the same slug, because the slug travelled up in the body as
`<!-- tea:id … -->` (map.with_id_marker) and is also recorded in
`.remote.json`. That is the reversal of "pushing is additive, the file is never
deleted"; it is deliberate, and AGENTS.md and references/format.md say so too.
ONE RULE, NO EXCEPTION: `--update` deletes as well. A PATCH is a push, and an
issue that has just been sent is no more local than one that was just created.
Two rules would put back exactly the question this removes — "is my copy the
fresh one?".
The deletion is the LAST thing that happens to an issue, and only after:
1. the api call returned (it did not raise, and `tea` exited 0), and
2. the answer is a dict carrying a plausible `number`, and on `--update`
the very number that was PATCHed (`confirmed_number`), and
3. `.remote.json` has been written with number -> slug.
Network down, non-2xx, a body that does not confirm the write, a mismatched
number: the file stays and the run stops. Nothing here removes a file it has not
just watched Gitea accept, and nothing removes a file for an issue it did not
send — `origin: local` work that has never been pushed is never touched.
push.py every local-only issue, dependencies first
push.py wire-sqlc-appclick one issue
push.py --update <id …> PATCH issues that are already in Gitea
push.py --dry-run validate only, no network, nothing deleted
Before anything is sent, each issue is validated against the canonical format
by the domain layer (exactly one type/*, English title with no type prefix,
`## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts
anyway; say why when you use it.
Dependencies are pushed in topological order so a parent is created after the
issues it depends on. A dependency that is still local-only is reported, not
silently dropped — the body's `## Depends on` prose is sent verbatim either
way, so nothing is lost, but the tracker shows no edge for it.
The graph goes up with them. Once an issue has its number, every `depends:`
entry that also has one becomes a **native Gitea link** — the same
`/dependencies` that every `pull.py` reads back, so the tracker shows the
blocking panel and refuses to close a blocked issue first. Topological order
means the blocker already has its number by then; no second pass is needed.
`--update` links whatever appeared in `depends:` since the last push. A link
the tracker already has is skipped, not re-POSTed. A dependency that stayed
local has no number and becomes no link — only the warning above.
REMOVING a link is OUT OF SCOPE. Push only ever adds: a dependency deleted
from `depends:` leaves its Gitea link standing, and nothing here will notice.
Unlink it in the web UI, or by hand with
`tea api -X DELETE --login "$GITEA_LOGIN" repos/OWNER/REPO/issues/N/dependencies`.
The `## Depends on` prose itself is never touched — slugs stay slugs and are
not rewritten to `#N`, so the body survives a pull -> push round trip byte for
byte. The link lives in Gitea's own graph, not in the text.
Missing labels are created with the canonical color and, for type/* and
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
`branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is
filled with the current git branch and goes up with the issue; one that is
already set is sent as written and never overwritten. Detached HEAD, or no repo
at all: no `ref` is sent and a warning says so. It is not written back to the
file any more — there is no file to write it back to; it comes down with the
next pull.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import subprocess
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import issue_index # noqa: E402
import map as gmap # noqa: E402
def select(issues, ids, update):
"""Which issues to send, and refuse the ambiguous combinations."""
if ids:
missing = [i for i in ids if i not in issues]
if missing:
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
chosen = list(ids)
else:
chosen = sorted(i for i in issues
if update or not issues[i].extra.get("gitea"))
if not chosen:
_gitea.die("nothing to push: every issue in the store is already in Gitea "
"(use --update to PATCH them, or issue_new.py to make one)")
if not update:
already = [i for i in chosen if issues[i].extra.get("gitea")]
if already:
_gitea.die("already in Gitea: %s — pass --update to PATCH them"
% ", ".join(already))
return chosen
def ledger_keys(remote_map, repo=None):
"""slug -> remote key, the reverse of `.remote.json`.
Where a dependency's number comes from once push has deleted its file. The
forward map is keyed by number because that is what a pull has in hand; a
push has a slug, so it needs the other direction. Same-repo entries win if a
slug somehow appears under two keys."""
out = {}
for key, slug in sorted(remote_map.items()):
if slug not in out or gmap.parse_remote_key(key)[0] == repo:
out[slug] = key
return out
def dep_state(iss, issues, pushing, key_of_id=None):
"""What each `depends:` entry is, as far as linking is concerned.
Yields (slug, remote_key, in_run) per dependency this run can say anything
about:
remote_key where the dependency lives in Gitea, or None while it is
local-only
in_run this push is about to give it one
A dependency's key is read from its `gitea:` field when the file is still
on disk, and from the ledger (`key_of_id`) when it is not — which, since
push deletes what it sends, is the normal state of an already-published
blocker. Without that fallback the graph would quietly lose an edge every
time a blocker was pushed before its dependent: the file is gone, the field
goes with it, and the link is never made.
A slug that is neither in the store nor in the ledger is dropped; it names
nothing this machine has ever seen, and validate() has already warned.
In the real run remote_key is all that matters — topological order means an
in-run blocker has already been stamped by the time its dependent is sent.
`--dry-run` has no numbers to stamp, so it leans on in_run to say which
links are coming and which cannot exist at all."""
key_of_id = key_of_id or {}
out = []
for d in iss.depends:
dep = issues.get(d)
key = (dep.extra.get("gitea") if dep is not None else None) or key_of_id.get(d)
if dep is None and not key:
continue
out.append((d, key or None, d in pushing))
return out
def confirmed_number(got, sent_number=None):
"""The number Gitea confirmed for a write, or None — the deletion gate.
Every local file this script removes is removed because this function
returned an int, so it is written to be boring and to say no by default.
An answer counts only when it is a dict carrying a positive integer
`number`, and, when `sent_number` is given (a PATCH, where we already know
which issue we addressed), the same number we sent.
`bool` is rejected explicitly: `True` is an `int` in Python and `number:
true` is not a confirmation of anything.
What this does NOT have to catch, because it never gets here: a non-2xx
answer or a `tea` that failed to run at all — `_gitea.api` exits on both,
and an exception in the transport propagates. The file survives all three
by never reaching the delete."""
if not isinstance(got, dict):
return None
n = got.get("number")
if isinstance(n, bool) or not isinstance(n, int) or n <= 0:
return None
if sent_number is not None and n != sent_number:
return None
return n
def drop_local(root, id):
"""Delete the local copy of an issue and its thread; return what went.
Deliberately dumb: it takes an id, not a decision. Whether an issue may be
dropped is decided by the caller, before this is reached, so the dangerous
half of the operation has no branches in it at all. There is exactly one
call site.
A missing file is not an error — an issue with no comments has no thread."""
gone = []
for p in (issue.path_of(root, id), _gitea.comments_path(root, id)):
if os.path.isfile(p):
os.remove(p)
gone.append(p)
return gone
def git_branch():
"""The branch HEAD is on, or None. The only git call these scripts make —
read, never write. A detached HEAD prints `HEAD` and outside a repo git
exits non-zero; both mean "no branch to name", which is not an error."""
try:
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True)
except OSError:
return None
name = r.stdout.strip()
if r.returncode != 0 or not name or name == "HEAD":
return None
return name
def main():
ap = argparse.ArgumentParser(description="Push local issues to Gitea")
ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)")
ap.add_argument("--update", action="store_true",
help="PATCH issues that already carry a gitea: field")
ap.add_argument("--dry-run", action="store_true", help="validate only, no network")
ap.add_argument("--force", action="store_true", help="push despite format violations")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
root = args.out
problem = issue.store_error(root)
if problem:
_gitea.die("%s — create an issue with issue_new.py first" % problem)
issues = issue.load_all(root)
chosen = select(issues, args.ids, args.update)
# ---- validate (domain layer, no network) -----------------------------
known = set(issues)
blocked = False
for id in chosen:
err, warn = issue.validate(issues[id], known_ids=known)
for w in warn:
_gitea.warn("%s: %s" % (id, w))
for e in err:
sys.stderr.write("%s: %s\n" % (id, e))
if err:
blocked = True
if blocked and not args.force:
_gitea.die("format violations (see above); --force overrides")
# ---- dependencies first ----------------------------------------------
edges = {i: [d for d in issues[i].depends if d in issues] for i in chosen}
order = [i for i in issue.topo_order(chosen, edges) if i in set(chosen)]
for c in issue.find_cycles(edges):
_gitea.warn("dependency cycle: %s" % " -> ".join(c))
# ---- branch: -> Gitea `ref` ------------------------------------------
# Only an empty field is filled: a branch written by hand is the author's
# decision and push does not argue with it. Nothing to read (detached HEAD,
# no repo) is not an error — the issue goes up without a `ref`. The value is
# set on the in-memory issue only; the file it came from is about to be
# deleted, and the branch comes back with the next pull.
blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)]
branch = git_branch() if blank else None
if branch:
for id in blank:
issues[id].extra[gmap.BRANCH_KEY] = branch
elif blank:
_gitea.warn("no current git branch (detached HEAD, or outside a git repo) "
"— no `ref` on: %s" % ", ".join(blank))
pushing = set(order)
if args.dry_run:
links = 0
# The ledger costs no request, so a dry run resolves an already-pushed
# blocker the same way the real run does.
key_of_id = ledger_keys(_gitea.load_map(root), args.repo)
for id in order:
iss = issues[id]
print("ok %s [type/%s] %s (%s)"
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
# Not one request is made here: everything below is read off the
# store. `#?` is a number this run has not handed out yet.
for slug, key, in_run in dep_state(iss, issues, pushing, key_of_id):
if key:
print(" link -> %s (%s)" % (key, slug))
links += 1
elif in_run:
print(" link -> #? (%s, created by this run)" % slug)
links += 1
else:
print(" no link: %s is local-only" % slug)
print("%d issue(s) would be %s, %d dependency link(s) would be created"
% (len(order), "updated" if args.update else "created", links))
return
login = _gitea.require_login()
base = _gitea.repo_base(args.repo)
repo = _gitea.repo_slug(login, args.repo)
wanted = sorted({l for id in order for l in issues[id].labels})
label_ids = _gitea.ensure_labels(login, base, gmap.label_specs(wanted), root) \
if wanted else {}
milestone_ids = {}
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
key_of_id = ledger_keys(remote_map, repo)
for id in order:
iss = issues[id]
# Local-only means "this machine has never sent it": no `gitea:` on the
# file AND no entry in the ledger. A blocker whose file push already
# dropped is in the ledger and is not one of these.
unsynced = [d for d in iss.depends
if d in issues and not issues[d].extra.get("gitea")
and d not in key_of_id and d not in pushing]
if unsynced:
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
% (id, ", ".join(unsynced)))
ms_id = None
if iss.milestone:
if iss.milestone not in milestone_ids:
milestone_ids[iss.milestone] = _gitea.resolve_milestone_id(
login, base, iss.milestone)
ms_id = milestone_ids[iss.milestone]
if ms_id is None:
_gitea.warn("%s: milestone %r does not exist in %s — not set"
% (id, iss.milestone, repo))
sent_number = gmap.number_of(iss)
if sent_number:
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH",
payload, payload_name="issue-%s" % id)
verb = "updated"
else:
payload = gmap.to_payload(iss, label_ids, ms_id)
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
payload_name="issue-%s" % id)
verb = "created"
# The gate. Below this line the local file is going to be deleted, so
# anything short of a confirmed write has to stop the run here.
number = confirmed_number(got, sent_number)
if number is None:
_gitea.die("%s: %s failed — the tracker's answer does not confirm the "
"write (%.200r). %s is untouched."
% (id, verb, got, issue.path_of(root, id)))
# The number is confirmed, so the ledger learns it now — before the
# label fix-up below, which can still fail, and well before the file is
# removed. `.remote.json` is what a later `pull.py N` uses to land on
# this slug again; an interrupted run must cost a re-pull, not a slug.
remote_map[gmap.remote_key(repo, number)] = id
key_of_id[id] = gmap.remote_key(repo, number)
_gitea.save_map(root, remote_map)
# Gitea occasionally drops labels on create — re-apply rather than
# trust the echo.
applied = {l.get("name", "") for l in got.get("labels") or []}
missing = [l for l in iss.labels if l in label_ids and l not in applied]
if missing:
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
payload_name="labels-%s" % id)
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
# The in-memory issue is stamped even though its file is going: the rest
# of this loop reads `gitea:` off it to link dependencies, and a later
# issue in topological order asks the same of this one.
gmap.apply_remote(iss, got, repo, _gitea.now_iso())
# Where the issue lives now. The number and the URL lead because this
# is the receipt: in a moment the local path is gone and this is the
# only address the issue has.
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
# ---- the graph, as Gitea's own links ------------------------------
# Blockers came first in topological order, so each one that is going
# to have a number has one already — stamped on the in-memory issue
# above, or read out of the ledger for one whose file an earlier push
# already dropped. The GET is the idempotence check: it costs one
# request per issue that has dependencies at all, and it is what makes
# a repeat push a no-op.
wanted_links = [(slug, gmap.parse_remote_key(key))
for slug, key, _ in dep_state(iss, issues, pushing, key_of_id)
if key]
if wanted_links:
have = _gitea.native_dep_pairs(login, base, number)
for slug, (drepo, dnum) in wanted_links:
if not dnum or (drepo, dnum) in have:
continue
if _gitea.add_dependency(login, base, number, drepo, dnum):
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
else:
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
"hand, or `pull.py %d` and push it again"
% (id, number, drepo, dnum, slug, number))
# ---- and now the local copy goes ----------------------------------
# The last thing that happens to this issue, after the write, the
# ledger, and the links. A failure above is a warning and lands here
# anyway: the issue IS in Gitea, so keeping a stale file beside it
# would put back exactly the two-copies question this removes.
for p in drop_local(root, id):
print(" dropped %s" % p)
print(" pull.py %d to work on it again" % number)
_gitea.save_map(root, remote_map)
path, n = issue_index.build(root)
print("index: %s%d issue(s)" % (path, n))
if __name__ == "__main__":
main()
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
remote.py — what exists in Gitea, one line each.
Discovery only: prints to stdout and writes nothing. The local store is a
store, not a search-results folder, so a listing never lands in it. Pick the
numbers here, then pull them.
#42 open type/task, tech/sql Wire sqlc into the repo layer
└─ local: wire-sqlc-appclick
The second line appears when the issue is already in the local store, so it is
obvious what a pull would refresh versus what it would add.
Usage:
remote.py [--state open|closed|all] [--label L]… [-q TEXT]
[--milestone M] [--limit N] [--repo owner/repo]
`--limit` here caps the LISTING: N lines out, closed ones among them. That is
not what the same flag means to `pull.py`, and the difference is not an
oversight — pull.py bounds what it writes, and this command writes nothing, so
there is nothing else for a limit to bound. Enumeration is the whole job.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
def main():
ap = argparse.ArgumentParser(description="List Gitea issues (stdout only, no files)")
ap.add_argument("--state", default="open", choices=["open", "closed", "all"])
ap.add_argument("--label", action="append", default=[],
help="filter by label; repeat for AND")
ap.add_argument("-q", "--query", help="search text in title/body")
ap.add_argument("--milestone", help="milestone id or title")
ap.add_argument("--limit", type=int, default=30)
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
login = _gitea.require_login()
base = _gitea.repo_base(args.repo)
payloads, ms_title = _gitea.list_issues(
login, base, state=args.state, labels=args.label, query=args.query,
milestone=args.milestone, limit=args.limit)
remote_map = _gitea.load_map(args.out)
repo = _gitea.repo_slug(login, args.repo) if remote_map else None
for p in payloads:
labels = ", ".join(l.get("name", "") for l in p.get("labels") or []) or "-"
print("#%-5d %-7s %-38s %s" % (p["number"], p.get("state", ""),
labels[:38], p.get("title", "")))
local = remote_map.get(gmap.remote_key(repo, p["number"])) if repo else None
if local:
print("%13s└─ local: %s" % ("", local))
scope = " in milestone %s" % ms_title if ms_title else ""
hint = ("--milestone %s" % args.milestone) if args.milestone else "<n>"
print("%d issue(s)%s — pull them with: pull.py %s" % (len(payloads), scope, hint))
if __name__ == "__main__":
main()