Compare commits
2 Commits
091dceec1d
...
d8bd927f1d
| Author | SHA1 | Date | |
|---|---|---|---|
| d8bd927f1d | |||
| 9234d8004f |
@@ -7,7 +7,7 @@
|
||||
{
|
||||
"name": "tea",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "2.0.0",
|
||||
"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.1.0",
|
||||
"author": {
|
||||
"name": "naudachu"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
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
|
||||
▲
|
||||
│ calls
|
||||
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
||||
```
|
||||
|
||||
`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`
|
||||
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
||||
(`/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
|
||||
that don't use the pinned login; `agents-sync` keeps every directory canonical
|
||||
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
||||
|
||||
@@ -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: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-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 |
|
||||
|
||||
## The layering
|
||||
@@ -22,6 +23,9 @@ skills/issue DOMAIN what an issue is: format, validation, dependency grap
|
||||
▲ offline — no tracker, no network, stdlib only
|
||||
│ imports
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
.claude-plugin/
|
||||
plugin.json plugin manifest
|
||||
marketplace.json marketplace catalog (makes `/plugin install` work)
|
||||
agents/
|
||||
tea-runner.md subagent (Haiku) that executes the scripts
|
||||
hooks/
|
||||
hooks.json registers the PreToolUse hook
|
||||
tea-guard.sh the guard (Python 3, no deps)
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
`depends:` is the authoritative edge list; the body's `## Depends on` section
|
||||
|
||||
@@ -34,6 +34,7 @@ assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
remote-updated: 2026-08-09T18:24:01Z
|
||||
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** |
|
||||
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
||||
| `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 |
|
||||
|
||||
Domain fields render first, in the order above; sync fields follow, sorted.
|
||||
|
||||
+44
-3
@@ -38,9 +38,10 @@ the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing |
|
||||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md` |
|
||||
| `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 |
|
||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
|
||||
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
|
||||
|
||||
Key forms for `<key>`: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo
|
||||
@@ -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
|
||||
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:
|
||||
|
||||
- **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
|
||||
and their meaning come from the domain taxonomy.
|
||||
|
||||
That is per-push and piecemeal: a repo only ever grows the labels its issues
|
||||
happened to use, so filtering by `type/bug` in the web UI stays impossible
|
||||
until someone pushes a bug. `labels.py` lays down the whole set — the 11
|
||||
`type/*` and `severity/*` names — in one run:
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/labels.py --dry-run # the plan, no writes
|
||||
python3 <skill-base-dir>/scripts/labels.py # create what is missing
|
||||
```
|
||||
|
||||
It reads the repo's labels first. An exactly-matching name is never re-created
|
||||
and never patched. A **lookalike** — `bug`, `Bug`, `type: bug`, `kind/bug` —
|
||||
is reported with its id and left alone: renaming somebody else's label is a
|
||||
decision, not a migration. A color or `exclusive` that drifted is printed, and
|
||||
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
|
||||
`comp/*` are open-ended by design and stay push-created.
|
||||
|
||||
A milestone must already exist in the repo — push attaches, it does not create.
|
||||
|
||||
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
|
||||
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
||||
and writes it back into the issue file; a value already there is never
|
||||
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
|
||||
a git repo no `ref` is sent and a warning names the issues that went up without
|
||||
one. Reading the branch is the only thing these scripts ask git for — they
|
||||
never check out, create, or write anything.
|
||||
|
||||
## What crosses the boundary, and what does not
|
||||
|
||||
| domain | Gitea | note |
|
||||
@@ -138,6 +179,7 @@ A milestone must already exist in the repo — push attaches, it does not create
|
||||
| `assignees` | `assignees[]` | logins |
|
||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
||||
| `depends` | — | slugs; seeded from `#N` on pull |
|
||||
| — | `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
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
in Gitea.
|
||||
`pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea.
|
||||
|
||||
## Drift
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -23,6 +23,7 @@ What crosses the boundary, and what does not:
|
||||
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
|
||||
@@ -58,6 +59,11 @@ DEFAULT_COLOR = "#ededed"
|
||||
# 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.
|
||||
@@ -124,6 +130,8 @@ def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=Non
|
||||
"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"):
|
||||
@@ -174,6 +182,11 @@ def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
|
||||
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
|
||||
|
||||
|
||||
|
||||
+69
-29
@@ -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
|
||||
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:
|
||||
--deps [--depth N] follow dependencies and pull them too
|
||||
--comments also fetch comments (single issue only)
|
||||
--cached skip issues already on disk instead of refetching
|
||||
--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
|
||||
@@ -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)
|
||||
|
||||
|
||||
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():
|
||||
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")
|
||||
@@ -67,8 +105,6 @@ def main():
|
||||
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("--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",
|
||||
help="skip issues already on disk instead of refetching")
|
||||
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()
|
||||
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 -----------------------------------------------------------
|
||||
if filtered:
|
||||
@@ -124,19 +165,23 @@ def main():
|
||||
queue = [(_gitea.get_issue(login, base, n), 0) for n in 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 ------------------------------------------------------------
|
||||
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 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:
|
||||
store_ids.add(id)
|
||||
number_of_id[number] = id
|
||||
|
||||
if args.cached and os.path.isfile(issue.path_of(root, id)):
|
||||
skipped.append(id)
|
||||
if args.cached and stored:
|
||||
skipped.append(id) # untouched, and not one request spent on it
|
||||
else:
|
||||
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||
@@ -144,6 +189,7 @@ def main():
|
||||
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))
|
||||
@@ -157,6 +203,11 @@ def main():
|
||||
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
|
||||
@@ -169,31 +220,20 @@ def main():
|
||||
iss.depends.append(slug)
|
||||
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)
|
||||
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)):
|
||||
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" % (
|
||||
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
||||
issue.path_of(root, id), " (cached)" if id in skipped else ""))
|
||||
if cpath:
|
||||
print("comments: %s" % cpath)
|
||||
issue.path_of(root, id), note))
|
||||
print("index: %s" % index_path)
|
||||
if args.deps:
|
||||
print("graph: run issue_tree.py (offline) to draw it")
|
||||
|
||||
@@ -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
|
||||
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).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -61,6 +67,21 @@ def select(issues, ids, update):
|
||||
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():
|
||||
ap = argparse.ArgumentParser(description="Push local issues to Gitea")
|
||||
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):
|
||||
_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:
|
||||
for id in order:
|
||||
iss = issues[id]
|
||||
|
||||
Reference in New Issue
Block a user