Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8bd927f1d | |||
| 9234d8004f |
@@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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
|
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,19 +165,23 @@ 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)
|
||||||
|
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)
|
store_ids.add(id)
|
||||||
number_of_id[number] = id
|
number_of_id[number] = id
|
||||||
|
if args.cached and stored:
|
||||||
if args.cached and os.path.isfile(issue.path_of(root, id)):
|
skipped.append(id) # untouched, and not one request spent on it
|
||||||
skipped.append(id)
|
|
||||||
else:
|
else:
|
||||||
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
||||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||||
@@ -144,6 +189,7 @@ def main():
|
|||||||
extra_numbers=extra,
|
extra_numbers=extra,
|
||||||
synced=_gitea.now_iso())
|
synced=_gitea.now_iso())
|
||||||
issue.save(root, iss)
|
issue.save(root, iss)
|
||||||
|
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
||||||
remote_map[gmap.remote_key(repo, number)] = id
|
remote_map[gmap.remote_key(repo, number)] = id
|
||||||
written.append(id)
|
written.append(id)
|
||||||
pending.append((id, unresolved))
|
pending.append((id, unresolved))
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user