2 Commits

Author SHA1 Message Date
naudachu d8bd927f1d feat: add the tea-runner execution agent
The skills carry meaning, the scripts carry work. Splitting the second
half onto a cheap model keeps the main session's context for the part
that needs judgement.

tea-runner is a Haiku subagent with Bash/Read/Grep/Glob/Skill and
nothing else. It loads /tea:sync or /tea:issue for the command table
rather than carrying its own copy, so the skills stay the single source
of truth for the script surface.

It executes and reports; it decides nothing. No Edit and no Write, so an
issue body is out of reach. No raw tea, no --force, no closing or
retitling, no pushing past the set it was handed, one retry maximum. A
failed validation, a missing type, an unpushed dependency come back as a
question in a `blocked:` line. The reply is a fixed receipt — commands
with ok/FAIL, touched paths, stderr verbatim — never a payload dump.

Knowledge still flows one way: nothing under skills/ knows the agent
exists, and deleting agents/ changes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:02:34 +05:00
naudachu 9234d8004f feat: work the sync backlog — comments, labels, refs, closed issues
Five tracker issues, all in the bridge layer except the last.

pull.py fetches comments by default (#6). The thread was reachable only
through --comments, and only for a single issue, so a bulk pull left every
local copy silently incomplete: a missing <id>.comments.md could mean "no
comments" or "never asked". Now every written issue gets its thread, in key
and filter mode alike; an empty one costs no request (the count rides in the
list payload) and writes no file, and a file left over from an earlier pull
is deleted. --cached skips the thread along with the body. The --comments
flag is gone.

labels.py bootstraps the canonical label set (#7). Labels used to appear as a
side effect of the first push that happened to use them, so a repo could not
be filtered by type/bug until somebody pushed a bug. The set is finite and
already described by the domain taxonomy — 6 type/* and 5 severity/* — which
makes it a run, not a decision. Names and exclusivity come from issue.TYPES /
SEVERITIES / EXCLUSIVE_NS, colors from map.label_specs; no list is duplicated.
An exact name is never re-created or patched. Lookalikes (bug, Bug, "type:
bug", kind/bug) are reported with their id and left alone — renaming somebody
else's label is a decision, not a migration. Color or exclusive drift is
printed, and changed only under --fix.

branch: carries Gitea's ref (#8). map.to_payload sends ref only when the field
is non-empty, since ref="" would clear whatever the server has; from_api reads
it back; push fills an empty one from `git rev-parse --abbrev-ref HEAD` and
writes it into the issue file. A hand-written value is never overwritten, on
create or on --update. Detached HEAD and running outside a repo warn and send
no ref. Reading the branch is the only thing these scripts ask of git. The
domain needs no change: unknown keys already ride in Issue.extra and render
after the domain fields.

Bulk pulls no longer store closed issues (#10). Filter mode wrote every
payload the server returned, so --state all dragged the closed backlog into a
store that gets read whole — INDEX.md, grep over tmp/issues/*.md. They are
still enumerated, the number left out goes to stderr, and an issue already on
disk is refreshed either way so the local copy learns it was closed instead of
staying open forever. --state closed stores them, and key mode is exempt: an
address is not a bulk read.

/tea:issue gains a "Writing a proper description" procedure (#9). Six steps
from reading an issue to issue_check.py, the rule that a missing fact is found
in the repository or asked about rather than invented, and the note that the
procedure is identical for origin: local and origin: gitea while delivery to
the tracker belongs to /tea:sync. No new script.

Verified: labels.py run for real against claude-skills/tea (9 created, 2
already present) and idempotent on a second run; pull.py exercised live for
the closed-skip, --state closed, key-mode and comment paths; the push write
path covered offline with the transport stubbed. skills/issue/scripts/ still
imports stdlib only, with no subprocess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:37:57 +05:00
12 changed files with 601 additions and 45 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
{ {
"name": "tea", "name": "tea",
"source": "./", "source": "./",
"description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login." "description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login."
} }
] ]
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "tea", "name": "tea",
"description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.", "description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.",
"version": "2.0.0", "version": "2.1.0",
"author": { "author": {
"name": "naudachu" "name": "naudachu"
}, },
+9
View File
@@ -25,6 +25,9 @@ skills/sync BRIDGE map.py md <-> Gitea JSON, pure functions, no I/O
_gitea.py login pin, tea api, pagination, filters _gitea.py login pin, tea api, pagination, filters
skills/use REFERENCE tea CLI docs for everything that is not an issue skills/use REFERENCE tea CLI docs for everything that is not an issue
skills/auth IDENTITY pin the login the whole tracker side runs under skills/auth IDENTITY pin the login the whole tracker side runs under
│ calls
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
``` ```
`skills/issue` never imports from `skills/sync`. Delete `skills/sync` and the `skills/issue` never imports from `skills/sync`. Delete `skills/sync` and the
@@ -56,6 +59,12 @@ the domain layer, it is in the wrong place.
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py` - `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
- `skills/use``tea` CLI reference for everything that is not an issue - `skills/use``tea` CLI reference for everything that is not an issue
(`/tea:use`); `references/tea/` holds the command docs (`/tea:use`); `references/tea/` holds the command docs
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
returns a compact receipt. Delegate batches (bulk pull, push a named set,
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
and no `Write`, may not `--force`, and may not decide what an issue says.
Delegating a single call costs more than running it inline — the win is the
loop, the retry, and the error triage.
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations - `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
that don't use the pinned login; `agents-sync` keeps every directory canonical that don't use the pinned login; `agents-sync` keeps every directory canonical
(`AGENTS.md` real file, `CLAUDE.md` symlink to it) (`AGENTS.md` real file, `CLAUDE.md` symlink to it)
+24
View File
@@ -10,6 +10,7 @@ A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforce
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline | | `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment | | `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment |
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand | | `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation | | `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
## The layering ## The layering
@@ -22,6 +23,9 @@ skills/issue DOMAIN what an issue is: format, validation, dependency grap
▲ offline — no tracker, no network, stdlib only ▲ offline — no tracker, no network, stdlib only
│ imports │ imports
skills/sync BRIDGE md <-> Gitea JSON, then over the wire skills/sync BRIDGE md <-> Gitea JSON, then over the wire
│ calls
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
``` ```
Delete `skills/sync` and the domain layer keeps working — issues that live only Delete `skills/sync` and the domain layer keeps working — issues that live only
@@ -87,12 +91,32 @@ This prevents silent fallback to the machine's default login (often a personal a
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data. `tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
## The tea-runner agent
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on
Haiku that does the second half in its own context and hands back a receipt —
what ran, what it touched, what failed, verbatim.
Delegate a **batch**: pull a milestone and rebuild the index, push the three
issues you just wrote, bootstrap the label set, post a comment from a file you
prepared. Spawning it for a single `pull.py 42` costs more than running the
command yourself; the saving is in the loop, the retry, and reading somebody
else's stderr.
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
type, a failed validation, an unpushed dependency come back as a question, not
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
session — the pinned login is enforced on every call it makes.
## Project layout ## Project layout
``` ```
.claude-plugin/ .claude-plugin/
plugin.json plugin manifest plugin.json plugin manifest
marketplace.json marketplace catalog (makes `/plugin install` work) marketplace.json marketplace catalog (makes `/plugin install` work)
agents/
tea-runner.md subagent (Haiku) that executes the scripts
hooks/ hooks/
hooks.json registers the PreToolUse hook hooks.json registers the PreToolUse hook
tea-guard.sh the guard (Python 3, no deps) tea-guard.sh the guard (Python 3, no deps)
+108
View File
@@ -0,0 +1,108 @@
---
name: tea-runner
description: Executes the tea plugin's scripts and reports back a compact receipt. Use for the mechanical half of tracker work — bulk pulls, pushing issues the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
tools: Bash, Read, Grep, Glob, Skill
model: haiku
---
# tea-runner — the execution layer
You run this project's issue scripts and hand back a short receipt. You are the
fourth layer of the plugin, below the three that carry meaning:
```
skills/issue DOMAIN what an issue is
skills/sync BRIDGE md <-> Gitea, over the wire
skills/use REFERENCE tea CLI docs
│ calls
tea-runner EXECUTION runs the scripts, reports the result
```
Knowledge still flows one way. You call those layers; nothing in them knows you
exist. **You hold no opinion about content.** Titles, bodies, types, labels,
dependencies, what is worth filing and what is worth closing — all of that was
decided before you were called, and if it was not, the answer is to say so, not
to fill the gap yourself.
## Where the commands come from
Load the skill, do not remember the flags:
- `/tea:sync``pull.py`, `push.py`, `comment.py`, `remote.py`, `labels.py`
- `/tea:issue``issue_check.py`, `issue_tree.py`, `issue_index.py`, `issue_new.py`
Invoke `Skill` with `tea:sync` or `tea:issue` at the start of the task, and use
the command table it gives you verbatim. The skill is the single source of
truth for the script surface; a flag you recall from another session is a
guess. If the skill does not document a flag, it does not exist — report that
instead of trying it.
## Hard rules
1. **No raw `tea`.** Every tracker call goes through a script in
`skills/sync/scripts/`. The one exception is a diagnostic the skill itself
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
the `tea-guard` hook substitutes the pinned login. Never name a login.
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
write files; you do not. If a task needs a body edited or a metadata field
changed by hand, stop and say which file and which field.
3. **Push only what you were told to push.** `push.py` publishes to a tracker
other people read. Run it with the ids the caller named, or with the filter
the caller named. Never widen the set, never run a bare `push.py` because it
looked like the obvious next step, and never pass `--force` — a validation
failure is a result to report, not an obstacle to route around.
4. **Do not close, delete, or retitle anything** on either side.
5. **One retry, maximum.** A command that fails twice is a finding. Do not
permute flags looking for one that works.
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
issue body back into your report. The scripts print compact output by
design; the caller reads the files it needs from disk.
## Procedure
1. Load the skill you need.
2. Run the commands. Prefer one filtered call over a loop —
`pull.py --milestone 6` is one request per 50 issues, `pull.py 41 42 43…`
is one per issue.
3. If a command exits non-zero, capture the last lines of stderr and stop that
branch. Keep going on independent branches.
4. Report.
## Report format
Your final message is the return value. Keep it under ~20 lines. No preamble,
no restatement of the request, no advice about what to do next.
```
ran:
pull.py --milestone 6 --state all ok 7 issues, 3 threads
issue_index.py ok INDEX.md rebuilt
push.py wire-sqlc-appclick FAIL exit 1
touched: tmp/issues/{a,b,c}.md, tmp/issues/INDEX.md
failed: push.py wire-sqlc-appclick
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
blocked: none
```
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
- `touched` — paths only. Never contents.
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
the cause. Quote it exactly; do not paraphrase an error.
- `blocked` — what you refused to decide, phrased as the question the caller
has to answer. `none` when there is nothing.
## Known stops
Report these and halt; none of them is yours to resolve.
| Condition | Report |
|---|---|
| no login pinned (`tea-guard` blocks, or a script points at `/tea:auth`) | `blocked: no pinned login — operator must run /tea:auth` |
| `issue_check.py` errors before a push | the validator's own lines, verbatim |
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
| a milestone or label does not exist in the repo | the script prints the real ones — pass that list through |
| a script asks for a decision (type, label, `--force`) | `blocked:` with the question |
+49
View File
@@ -99,6 +99,55 @@ afterwards, and `issue_index.py` to refresh the table.
If the issue is synced (`origin: gitea`), your edit is local until you run If the issue is synced (`origin: gitea`), your edit is local until you run
`push.py --update` from `/tea:sync`. Nothing tracks that drift automatically. `push.py --update` from `/tea:sync`. Nothing tracks that drift automatically.
## Writing a proper description
Issues get filed on the run — "comments aren't pulled", "the guard broke".
That is a request, not a statement of work: no reproduction steps, no
`path/file:line`, acceptance criteria nobody can check. Rewriting one into the
canonical format is a procedure, not improvisation.
1. **Read the issue whole**, and everything it points at — the ids in
`depends:`, the `## Spec` target, the files it names.
2. **Determine the type and its template.** The `type/*` label selects one of
the templates in [`references/format.md`](references/format.md), and that
template's section list is the shape you are aiming at. If the label is
missing or wrong, decide it now and fix `labels:`; promoting a `type/draft`
to a concrete type is this same step.
3. **Locate the anchor points in the code.** Grep the repo for every file,
symbol, command, and error string the issue mentions, until you can name
lines:
```bash
grep -rn 'GITEA_LOGIN' hooks/ skills/
```
Work that does not exist yet still has anchor points — the files the change
will land in, and the ones that will call it.
4. **Gather the missing context.** What has to be there when you are done:
- code references in the `path/file.ext:line` form, for every place the
change lands;
- reproduction steps — exact commands and their real output (`type/bug`
splits them across `## Steps to reproduce` / `## Expected` / `## Actual`);
- acceptance criteria that are objectively checkable: a command that exits
0, a file that exists, a section that is present — not aspirations;
- a real value for `## Spec` — a repo path, a URL, or the literal `none`.
**A missing fact is either found in the repository or becomes a question to
the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in
`## Spec` as the legitimate answer it is — never a plausible-looking link.
5. **Rewrite the sections** with Edit: every section of the template, in the
template's order, English headers and Russian prose. Replace the body; do
not append a second telling of the same issue below the old one.
6. **Check it:**
```bash
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
```
Errors mean malformed, warnings mean the type's template is not fully
filled in. Re-run `issue_index.py` if the labels changed.
The procedure is identical for `origin: local` and `origin: gitea` — it works
on `tmp/issues/<id>.md`, and this layer does not know the difference. Getting
the rewritten body into the tracker is a separate decision — `push.py --update`
in `/tea:sync` — and is no part of this.
## Dependency graph ## Dependency graph
`depends:` is the authoritative edge list; the body's `## Depends on` section `depends:` is the authoritative edge list; the body's `## Depends on` section
+2
View File
@@ -34,6 +34,7 @@ assignees: [naudachu]
milestone: v0.2 milestone: v0.2
depends: [migrate-schema] depends: [migrate-schema]
origin: gitea origin: gitea
branch: feat/wire-sqlc
gitea: claude-skills/tea#42 gitea: claude-skills/tea#42
remote-updated: 2026-08-09T18:24:01Z remote-updated: 2026-08-09T18:24:01Z
synced: 2026-08-09T18:40:00Z synced: 2026-08-09T18:40:00Z
@@ -55,6 +56,7 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
| `depends` | domain | ids this issue depends on — **the authoritative graph** | | `depends` | domain | ids this issue depends on — **the authoritative graph** |
| `origin` | domain | `local`, or the name of a tracker this also lives in | | `origin` | domain | `local`, or the name of a tracker this also lives in |
| `gitea` | sync | the handle in that tracker: `owner/repo#N` | | `gitea` | sync | the handle in that tracker: `owner/repo#N` |
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
| `url`, `synced`, `remote-updated`, `comments` | sync | bookkeeping | | `url`, `synced`, `remote-updated`, `comments` | sync | bookkeeping |
Domain fields render first, in the order above; sync fields follow, sorted. Domain fields render first, in the order above; sync fields follow, sorted.
+44 -3
View File
@@ -38,9 +38,10 @@ the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
| Script | What it does | | Script | What it does |
|---|---| |---|---|
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing | | `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md` | | `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty |
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success | | `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success |
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread | | `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
| `map.py`, `_gitea.py` | the two layers the commands import — not commands | | `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 Key forms for `<key>`: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo
@@ -84,6 +85,21 @@ not one per issue. Filters AND together; `--state` defaults to `open`;
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed **A pull overwrites the local body.** It is a fetch, not a merge — unpushed
local edits are lost. `--cached` skips issues already on disk. local edits are lost. `--cached` skips issues already on disk.
**Closed issues stay out of the store.** In filter mode they are enumerated
but not written: `--state all` still shows the whole picture, only `--state
closed` puts one on disk, and the number left out goes to stderr. An issue
already on disk is refreshed either way — the local copy learns it was closed
instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a
closed issue as always, because an address is not a bulk read.
**Comments come with every pull** — there is no flag. An issue that has a
thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter
mode alike, and the issue's output line says how many. An issue with none
costs nothing: the count arrives in the list payload, so no request is made
and no file is written — and a file left over from a thread that has since
been emptied is deleted. `--cached` skips the thread along with the body, so a
skipped issue makes no request at all.
Two traps this handles for you: Two traps this handles for you:
- **Gitea silently ignores an unresolvable milestone filter** and returns the - **Gitea silently ignores an unresolvable milestone filter** and returns the
@@ -125,8 +141,33 @@ Missing labels are created with the canonical color and, for `type/*` and
(tea 0.14.2), so it goes through `tea api`. Colors live in `map.py`; the names (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. and their meaning come from the domain taxonomy.
That is per-push and piecemeal: a repo only ever grows the labels its issues
happened to use, so filtering by `type/bug` in the web UI stays impossible
until someone pushes a bug. `labels.py` lays down the whole set — the 11
`type/*` and `severity/*` names — in one run:
```bash
python3 <skill-base-dir>/scripts/labels.py --dry-run # the plan, no writes
python3 <skill-base-dir>/scripts/labels.py # create what is missing
```
It reads the repo's labels first. An exactly-matching name is never re-created
and never patched. A **lookalike**`bug`, `Bug`, `type: bug`, `kind/bug`
is reported with its id and left alone: renaming somebody else's label is a
decision, not a migration. A color or `exclusive` that drifted is printed, and
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
`comp/*` are open-ended by design and stay push-created.
A milestone must already exist in the repo — push attaches, it does not create. A milestone must already exist in the repo — push attaches, it does not create.
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
and writes it back into the issue file; a value already there is never
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
a git repo no `ref` is sent and a warning names the issues that went up without
one. Reading the branch is the only thing these scripts ask git for — they
never check out, create, or write anything.
## What crosses the boundary, and what does not ## What crosses the boundary, and what does not
| domain | Gitea | note | | domain | Gitea | note |
@@ -138,6 +179,7 @@ A milestone must already exist in the repo — push attaches, it does not create
| `assignees` | `assignees[]` | logins | | `assignees` | `assignees[]` | logins |
| `milestone` | `milestone.title` | resolved to an id on write | | `milestone` | `milestone.title` | resolved to an id on write |
| `depends` | — | slugs; seeded from `#N` on pull | | `depends` | — | slugs; seeded from `#N` on pull |
| — | `ref` | lands in `branch:`; sent only when non-empty |
| — | `number`, `html_url` | lands in `gitea:` / `url:` | | — | `number`, `html_url` | lands in `gitea:` / `url:` |
`depends:` is always slugs. The body's `## Depends on` section is human prose `depends:` is always slugs. The body's `## Depends on` section is human prose
@@ -146,8 +188,7 @@ 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. translator that edits prose churns the body on every round trip.
Comments are **pull-only** in the store: `<id>.comments.md` is written by Comments are **pull-only** in the store: `<id>.comments.md` is written by
`pull.py --comments` and `comment.py`, and editing it by hand changes nothing `pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea.
in Gitea.
## Drift ## Drift
+236
View File
@@ -0,0 +1,236 @@
#!/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.
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("/", "-"),
out_root=issue.ISSUE_ROOT)
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("/", "-"),
out_root=issue.ISSUE_ROOT)
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()
+13
View File
@@ -23,6 +23,7 @@ What crosses the boundary, and what does not:
milestone milestone.title resolved to an id on write milestone milestone.title resolved to an id on write
depends — slugs; #N is translated at the edge depends — slugs; #N is translated at the edge
— number, html_url lands in extra as gitea:/url: — 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:` is the authoritative graph and is always slugs. The body's
`## Depends on` section is human prose and is passed through UNCHANGED in both `## Depends on` section is human prose and is passed through UNCHANGED in both
@@ -58,6 +59,11 @@ DEFAULT_COLOR = "#ededed"
# that an issue exists somewhere else; only this module knows where. # that an issue exists somewhere else; only this module knows where.
ORIGIN = "gitea" 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): def label_specs(names):
"""{name: {color, description, exclusive}} for the transport to create. """{name: {color, description, exclusive}} for the transport to create.
@@ -124,6 +130,8 @@ def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=Non
"url": payload.get("html_url", ""), "url": payload.get("html_url", ""),
"synced": synced or "", "synced": synced or "",
} }
if payload.get("ref"):
extra[BRANCH_KEY] = payload["ref"]
if payload.get("updated_at"): if payload.get("updated_at"):
extra["remote-updated"] = payload["updated_at"] extra["remote-updated"] = payload["updated_at"]
if payload.get("comments"): if payload.get("comments"):
@@ -174,6 +182,11 @@ def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
payload["milestone"] = milestone_id payload["milestone"] = milestone_id
if include_state: if include_state:
payload["state"] = iss.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 return payload
+79 -39
View File
@@ -21,10 +21,25 @@ 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 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. 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. The limit is on the write, not on the selection —
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.
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).
Other flags: Other flags:
--deps [--depth N] follow dependencies and pull them too --deps [--depth N] follow dependencies and pull them too
--comments also fetch comments (single issue only) --cached skip issues already on disk (body AND comments)
--cached skip issues already on disk instead of refetching
--repo owner/repo default: auto-detect from the CWD git remote --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 Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
@@ -55,6 +70,29 @@ def id_for(payload, store_ids, remote_map, repo, root):
return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids) return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids)
def comments_path(root, id):
"""Where an issue's comment thread lives — beside it, under the same slug."""
return os.path.join(root, "%s.comments.md" % 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(): def main():
ap = argparse.ArgumentParser(description="Pull Gitea issues into the local store") 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("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL")
@@ -67,8 +105,6 @@ def main():
ap.add_argument("--limit", type=int, default=100, help="filter mode cap (default: 100)") ap.add_argument("--limit", type=int, default=100, help="filter mode cap (default: 100)")
ap.add_argument("--deps", action="store_true", help="follow dependencies and pull them") ap.add_argument("--deps", action="store_true", help="follow dependencies and pull them")
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)") ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
ap.add_argument("--comments", action="store_true",
help="also fetch comments (single issue only)")
ap.add_argument("--cached", action="store_true", ap.add_argument("--cached", action="store_true",
help="skip issues already on disk instead of refetching") 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("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
@@ -100,7 +136,12 @@ def main():
number_of_id = {gmap.parse_remote_key(k)[1]: v for k, v in remote_map.items() 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} if gmap.parse_remote_key(k)[0] == repo}
written, skipped, pending = [], [], [] # 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 ----------------------------------------------------------- # ---- seeds -----------------------------------------------------------
if filtered: if filtered:
@@ -124,29 +165,34 @@ def main():
queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers] queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers]
seen_numbers = set(numbers) seen_numbers = set(numbers)
if args.comments and len(queue) > 1:
_gitea.die("--comments works on a single issue; loop over the numbers instead")
# ---- walk ------------------------------------------------------------ # ---- walk ------------------------------------------------------------
while queue: while queue:
payload, depth = queue.pop(0) payload, depth = queue.pop(0)
number = payload["number"] number = payload["number"]
id = id_for(payload, store_ids, remote_map, repo, root) id = id_for(payload, store_ids, remote_map, repo, root)
store_ids.add(id) stored = os.path.isfile(issue.path_of(root, id))
number_of_id[number] = id
if args.cached and os.path.isfile(issue.path_of(root, id)): # Closed and not already ours: nothing is written and nothing is asked
skipped.append(id) # of the server for it, not even its comments. 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)
else: else:
extra = _gitea.native_deps(login, base, number) if args.deps else [] store_ids.add(id)
iss, unresolved = gmap.from_api(payload, id, repo, number_of_id[number] = id
id_for_number=number_of_id, if args.cached and stored:
extra_numbers=extra, skipped.append(id) # untouched, and not one request spent on it
synced=_gitea.now_iso()) else:
issue.save(root, iss) extra = _gitea.native_deps(login, base, number) if args.deps else []
remote_map[gmap.remote_key(repo, number)] = id iss, unresolved = gmap.from_api(payload, id, repo,
written.append(id) id_for_number=number_of_id,
pending.append((id, unresolved)) extra_numbers=extra,
synced=_gitea.now_iso())
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: if args.deps and depth < args.depth:
child_numbers = (gmap.numbers_in_body(payload.get("body") or "") child_numbers = (gmap.numbers_in_body(payload.get("body") or "")
@@ -157,6 +203,11 @@ def main():
seen_numbers.add(n) seen_numbers.add(n)
queue.append((_gitea.get_issue(login, base, n), depth + 1)) 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 -- # ---- second pass: dependencies that were not yet known on first write --
for id, unresolved in pending: for id, unresolved in pending:
newly = [number_of_id[n] for n in unresolved newly = [number_of_id[n] for n in unresolved
@@ -169,31 +220,20 @@ def main():
iss.depends.append(slug) iss.depends.append(slug)
issue.save(root, iss) issue.save(root, iss)
cpath = None
if args.comments:
id = written[0] if written else skipped[0]
_repo, number = gmap.parse_remote_key(issue.load(root, id).extra.get("gitea", ""))
comments = _gitea.get_comments(login, base, number)
cpath = os.path.join(root, "%s.comments.md" % id)
if comments:
with open(cpath, "w") as f:
f.write(gmap.render_comments(comments))
else:
if os.path.isfile(cpath):
os.remove(cpath) # stale file from an earlier pull
cpath = None
_gitea.save_map(root, remote_map) _gitea.save_map(root, remote_map)
index_path, _ = issue_index.build(root) index_path, _ = issue_index.build(root)
# Compact output — the only thing that lands in the model's context. # 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.
for id in sorted(set(written) | set(skipped)): for id in sorted(set(written) | set(skipped)):
iss = issue.load(root, id) iss = issue.load(root, id)
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" % ( print("%s [%s] %s%s %s%s" % (
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state, id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
issue.path_of(root, id), " (cached)" if id in skipped else "")) issue.path_of(root, id), note))
if cpath:
print("comments: %s" % cpath)
print("index: %s" % index_path) print("index: %s" % index_path)
if args.deps: if args.deps:
print("graph: run issue_tree.py (offline) to draw it") print("graph: run issue_tree.py (offline) to draw it")
+34
View File
@@ -25,10 +25,16 @@ way, so nothing is lost, but the `#N` cross-links will be missing.
Missing labels are created with the canonical color and, for type/* and Missing labels are created with the canonical color and, for type/* and
severity/*, `exclusive: true` — `tea labels create` cannot set that field. 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 written back to the file; one that is
already set is never touched. Detached HEAD, or no repo at all: no `ref` is
sent and a warning says so.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth). Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
""" """
import argparse import argparse
import os import os
import subprocess
import sys import sys
_HERE = os.path.dirname(os.path.abspath(__file__)) _HERE = os.path.dirname(os.path.abspath(__file__))
@@ -61,6 +67,21 @@ def select(issues, ids, update):
return chosen return chosen
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(): def main():
ap = argparse.ArgumentParser(description="Push local issues to Gitea") 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("ids", nargs="*", help="issue ids (default: every local-only issue)")
@@ -99,6 +120,19 @@ def main():
for c in issue.find_cycles(edges): for c in issue.find_cycles(edges):
_gitea.warn("dependency cycle: %s" % " -> ".join(c)) _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`.
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))
if args.dry_run: if args.dry_run:
for id in order: for id in order:
iss = issues[id] iss = issues[id]