From 091dceec1d7d89d140c9d92662c390cfcd6011b1 Mon Sep 17 00:00:00 2001 From: naudachu Date: Sun, 9 Aug 2026 23:37:32 +0500 Subject: [PATCH] refactor: split issue domain from Gitea transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An issue was a Gitea row that happened to be cached locally: its identity was the tracker's number (42.md), its dependencies were tracker numbers (depends: [#12]), and a local issue existed only as a draft that push deleted on success. Nothing could be planned or tracked without a tracker. Split into layers, with knowledge flowing one way: skills/issue DOMAIN what an issue is: format, validation, dep graph ^ offline; stdlib imports only, no subprocess | imports skills/sync BRIDGE map.py md <-> Gitea JSON, pure, no I/O _gitea.py login pin, api, pagination, filters skills/use REFERENCE tea CLI docs for non-issue entities skills/issue never imports skills/sync. Delete the sync layer and the domain keeps working. Identity is now a slug derived from the title (wire-sqlc-appclick.md) and is stable across retitles and pushes. Tracker numbers live in a `gitea:` field, never in a file name and never in `depends:`; the pair is indexed in .remote.json, which is a cache over the files, not a second source of truth. Behavior changes: - Pushing is additive. The file is never deleted; it gains gitea:/url:/ synced: and origin: flips from local to gitea. `origin: local` is a durable state, not a pending one. - Pushes go in topological order so dependencies get numbers first. - The dependency graph is computed offline from `depends:` metadata; body prose is passed through unchanged in both directions rather than being rewritten between slugs and #N. - `origin` is domain-owned (whether work exists elsewhere is a fact about the work); the handle and how to reach it stay with sync. Script moves: issue_get.py -> sync/pull.py issue_push.py -> sync/push.py issue_list.py -> sync/remote.py issue_index.py -> issue/issue_index.py _tea.py -> split into issue/issue.py, sync/map.py, sync/_gitea.py New: issue/issue_new.py, issue/issue_check.py, issue/issue_tree.py, and sync/comment.py — comment posting was the last issue operation still hand-rolled through raw `tea api`. references/issue-format.md moves to skills/issue/references/format.md; label hex colors move out of it into map.py, since a color is how a tracker paints a chip, not what an issue is. Verified: offline path end to end (new, check, tree, index, push --dry-run) and read-only against Gitea (remote listing, pull with mapping, comment guard). Write paths of push.py and comment.py are not exercised here. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 6 +- AGENTS.md | 80 +++- README.md | 82 +++- skills/issue/SKILL.md | 160 ++++--- .../references/format.md} | 171 ++++--- skills/issue/scripts/issue.py | 445 ++++++++++++++++++ skills/issue/scripts/issue_check.py | 69 +++ skills/issue/scripts/issue_index.py | 89 ++++ skills/issue/scripts/issue_new.py | 187 ++++++++ skills/issue/scripts/issue_tree.py | 96 ++++ skills/sync/SKILL.md | 173 +++++++ skills/sync/scripts/_gitea.py | 316 +++++++++++++ skills/sync/scripts/comment.py | 98 ++++ skills/sync/scripts/map.py | 195 ++++++++ skills/sync/scripts/pull.py | 203 ++++++++ skills/sync/scripts/push.py | 177 +++++++ skills/sync/scripts/remote.py | 68 +++ skills/use/SKILL.md | 171 ++----- skills/use/scripts/_tea.py | 406 ---------------- skills/use/scripts/issue_get.py | 261 ---------- skills/use/scripts/issue_index.py | 94 ---- skills/use/scripts/issue_list.py | 51 -- skills/use/scripts/issue_push.py | 188 -------- 24 files changed, 2504 insertions(+), 1284 deletions(-) rename skills/{use/references/issue-format.md => issue/references/format.md} (54%) create mode 100644 skills/issue/scripts/issue.py create mode 100644 skills/issue/scripts/issue_check.py create mode 100644 skills/issue/scripts/issue_index.py create mode 100644 skills/issue/scripts/issue_new.py create mode 100644 skills/issue/scripts/issue_tree.py create mode 100644 skills/sync/SKILL.md create mode 100644 skills/sync/scripts/_gitea.py create mode 100644 skills/sync/scripts/comment.py create mode 100644 skills/sync/scripts/map.py create mode 100644 skills/sync/scripts/pull.py create mode 100644 skills/sync/scripts/push.py create mode 100644 skills/sync/scripts/remote.py delete mode 100755 skills/use/scripts/_tea.py delete mode 100755 skills/use/scripts/issue_get.py delete mode 100755 skills/use/scripts/issue_index.py delete mode 100755 skills/use/scripts/issue_list.py delete mode 100755 skills/use/scripts/issue_push.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7edc3f0..6d5835b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ { "name": "tea", "source": "./", - "description": "Gitea CLI (tea) reference plus a mandatory-login guard. Ships /tea:auth, /tea:use, /tea:issue, issue scripts with a local greppable cache, and a PreToolUse hook that blocks any tea command that would touch Gitea without --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, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login." } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3f6cb2b..c38a93b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,10 +1,10 @@ { "name": "tea", - "description": "Gitea CLI (tea) reference plus a mandatory-login guard. Ships /tea:auth (pin a login), /tea:use (command reference), /tea:issue (draft and push issues in a canonical format), scripts that keep issues in a flat greppable local cache, and a PreToolUse hook that blocks any tea command that would touch Gitea without --login.", - "version": "1.2.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, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.", + "version": "2.0.0", "author": { "name": "naudachu" }, "license": "MIT", - "keywords": ["gitea", "tea", "cli", "git", "login-guard"] + "keywords": ["gitea", "cli", "git", "issues", "login-guard"] } diff --git a/AGENTS.md b/AGENTS.md index 47f4ff5..0cecbee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,22 +2,76 @@ ## Project goals -1. **Unify and systematize issue workflow** for the development team with minimal context usage. Issue operations (create, fetch, format) are wrapped in scripts so agents spend tokens on the task, not on re-deriving commands and formats. -2. **Route all Gitea interaction through the `tea` CLI via scripts** instead of direct ad-hoc calls wherever possible. Scripts give deterministic, reviewable behavior; the `tea-guard` hook enforces that every `tea` invocation runs under the operator-pinned login. +1. **Unify and systematize issue workflow** for the development team with + minimal context usage. Issue operations are wrapped in scripts so agents + spend tokens on the task, not on re-deriving commands and formats. +2. **Keep the tracker out of the work.** An issue is a unit of work first and a + Gitea row second. The two are separate layers, and the first one does not + know the second exists. +3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of + direct ad-hoc calls wherever possible. Scripts give deterministic, + reviewable behavior; the `tea-guard` hook enforces that every `tea` + invocation runs under the operator-pinned login. + +## Layers + +The hard rule of this repo. Knowledge flows one way only: + +``` +skills/issue DOMAIN what an issue is: format, validation, dependency graph + ▲ offline — no tracker, no network, stdlib imports only + │ imports +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 +``` + +`skills/issue` never imports from `skills/sync`. Delete `skills/sync` and the +domain layer keeps working. The check is mechanical — every import under +`skills/issue/scripts/` is stdlib, and `subprocess` is not among them: + +```bash +grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u +``` + +If a tracker concept (issue number, login, HTTP call, label color) shows up in +the domain layer, it is in the wrong place. ## Repo layout - `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`) -- `skills/use` — `tea` CLI reference, loaded on demand (`/tea:use`); `references/` holds command docs and the canonical issue format; `scripts/` holds the issue scripts: - - `issue_get.py` — fetch issue(s) into the local grep cache `tmp/issues/`, by key or by filter (`--milestone`, `--label`, `-q`; one request per 50 issues); `--deps` walks the dependency graph and writes `tree-.md` - - `issue_push.py` — validate a local draft, create missing labels, POST it, delete the draft - - `issue_list.py` — discovery to stdout; `issue_index.py` — rebuild `tmp/issues/INDEX.md`; `_tea.py` — shared login/api/format helpers -- `skills/issue` — draft an issue locally in the canonical format, then push it (`/tea:issue`) +- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline + - `references/format.md` — canonical issue format; single source of truth + - `scripts/issue.py` — domain module: slug identity, parse/render, validation, + taxonomy, dependency graph + - `scripts/issue_new.py` — create a local issue from its type template + - `scripts/issue_check.py` — validate against the format + - `scripts/issue_tree.py` — draw the dependency graph + - `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md` +- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`) + - `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here + - `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters, + label ids, the remote-id map + - `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 +- `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) -## Local issue cache +## Local issue store -`tmp/issues/` (gitignored) is a **cache and a drafting area, not a mirror**: no -drift tracking, no sync back. Fetched issues are flat greppable markdown with -one metadata field per line; drafts live in `tmp/issues/drafts/` until -`issue_push.py` creates them in Gitea and removes the local file. -- `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) +`tmp/issues/` (gitignored) is **the store, not a cache of Gitea**. One flat +markdown file per issue, named by its slug, with one metadata field per line so +plain grep works without a parser. + +- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number. + Numbers live in the `gitea:` field. +- `origin: local` is a durable state. An issue that never leaves this machine is + complete and valid, not a draft. +- Pushing is additive: the file is never deleted, it gains `gitea:` / `url:` / + `synced:`. +- Pulling overwrites the body — a fetch, not a merge. +- No drift tracking. `synced:` tells you how old your copy is; re-pull when it + matters. diff --git a/README.md b/README.md index e5e55a0..c14a032 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,28 @@ A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforce | Piece | What it does | |---|---| | `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project | -| `/tea:use` skill | Tea CLI reference — loads command docs on demand | -| `/tea:issue` skill | Drafts issues locally in a canonical format (typed labels, fixed sections), then pushes them | -| Issue scripts | Fetch issues into a flat, greppable local cache (`tmp/issues/`), walk dependency trees, push drafts | +| `/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-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation | +## The layering + +An issue is a unit of work first and a Gitea row second. Those are two layers, +and knowledge flows one way: + +``` +skills/issue DOMAIN what an issue is: format, validation, dependency graph + ▲ offline — no tracker, no network, stdlib only + │ imports +skills/sync BRIDGE md <-> Gitea JSON, then over the wire +``` + +Delete `skills/sync` and the domain layer keeps working — issues that live only +on your machine are first-class, not drafts waiting to be uploaded. That is the +point of the split: you can plan, write, validate, and track work without a +tracker, and publish only what you choose to. + ## Prerequisites - **Claude Code** — CLI, desktop app, or IDE extension @@ -41,7 +58,7 @@ This is a Claude Code plugin — install it through the plugin marketplace, not /plugin install tea@tea ``` -The skills (`/tea:auth`, `/tea:use`, `/tea:issue`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later. +The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later. > The marketplace registration is written to `extraKnownMarketplaces` and the plugin to `enabledPlugins` in your settings automatically — you don't edit those by hand. There is **no** top-level `"plugins"` settings key; if you've added one from older instructions, remove it. @@ -53,7 +70,9 @@ Run `/tea:auth` once per project. Claude will list your available Gitea logins a /tea:auth ``` -After that, use `/tea:use` to look up commands, or just ask Claude to do something with Gitea and it will load the reference automatically. +After that, just ask Claude to do something with issues or Gitea — it loads the +right skill automatically. `/tea:auth` is only needed for the tracker side; +`/tea:issue` works without any login at all. ## How the login guard works @@ -79,23 +98,42 @@ hooks/ tea-guard.sh the guard (Python 3, no deps) skills/ auth/SKILL.md /tea:auth skill - use/SKILL.md /tea:use skill - use/references/tea/ tea CLI reference docs - use/references/issue-format.md canonical issue format (types, templates) - use/scripts/ issue scripts (Python 3, no deps): - issue_get.py fetch issues into tmp/issues/, --deps walks the graph - issue_push.py validate a local draft, create labels, POST, drop the draft - issue_list.py discovery listing to stdout - issue_index.py rebuild tmp/issues/INDEX.md (no network) - _tea.py shared login / api / on-disk-format helpers - issue/SKILL.md /tea:issue skill + issue/ /tea:issue — the domain layer, offline + SKILL.md + references/format.md canonical issue format (identity, types, templates) + scripts/ Python 3, stdlib only, no network: + issue.py domain module: slug identity, parse/render, + validation, taxonomy, dependency graph + issue_new.py create a local issue from its type template + issue_check.py validate against the format + issue_tree.py draw the dependency graph + issue_index.py rebuild tmp/issues/INDEX.md + sync/ /tea:sync — the bridge to Gitea + SKILL.md + scripts/ + map.py md <-> Gitea JSON, pure functions, no I/O + _gitea.py transport: login pin, tea api, pagination, filters + pull.py Gitea -> tmp/issues/ + push.py tmp/issues/ -> Gitea (additive; never deletes) + remote.py discovery listing to stdout + comment.py post or edit a comment + use/ /tea:use — tea CLI reference (non-issue entities) + SKILL.md + references/tea/ command docs ``` -## Local issue cache +## Local issue store -The scripts keep issues in `tmp/issues/` (gitignore it) as flat markdown with -one metadata field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` -works without a parser. It is a **cache and a drafting area, not a mirror**: -nothing tracks drift and nothing syncs back. Drafts written during planning -live in `tmp/issues/drafts/` and are deleted once `issue_push.py` creates them -in Gitea. +Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata +field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without +a parser. + +It is **the store, not a cache of Gitea**: + +- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers + live in a `gitea:` field. +- `origin: local` is a durable state. An issue that never leaves your machine is + complete and valid. +- Pushing is additive — the file gains `gitea:` / `url:` / `synced:` and stays + put. Pulling overwrites the body: a fetch, not a merge. +- Nothing tracks drift. `synced:` tells you how old your copy is. diff --git a/skills/issue/SKILL.md b/skills/issue/SKILL.md index 3b7213e..66c1a0f 100644 --- a/skills/issue/SKILL.md +++ b/skills/issue/SKILL.md @@ -1,73 +1,125 @@ --- name: issue -description: Create a Gitea issue in the project's canonical format. Run when the user asks to file/create an issue, or types /tea:issue. Writes a local draft during planning, then pushes it with issue_push.py, which validates the format, ensures exclusive type/* labels exist, and posts via tea api. +description: Work with this project's issues as units of work — create, read, grep, validate, and walk their dependency graph. Entirely offline; issues are local markdown files and need no tracker. Load when the user asks to file/create an issue, read or find issues, check an issue against the format, or see what depends on what. For pushing to or pulling from Gitea, load /tea:sync instead. --- -# /tea:issue — draft locally, push when agreed +# /tea:issue — issues as units of work -Thin procedure on top of the canonical format defined in -[`../use/references/issue-format.md`](../use/references/issue-format.md). -Read that file first — it is the single source of truth for types, labels, -templates, and language rules. +An issue is a markdown file in `tmp/issues/`. This skill covers everything you +do **with** an issue: writing one, reading one, checking it against the +canonical format, and walking the dependency graph. -Two phases, deliberately separated: planning writes **local files only** (no -network, no `tea`), and one push turns them into real issues. Scripts live in -`../use/scripts/` (see `/tea:use` for the full set). +**Nothing here touches the network.** No `tea`, no Gitea, no login. An issue +that lives only on this machine is a first-class issue, not a draft waiting to +be uploaded. Synchronizing with a tracker is a separate, optional layer — +`/tea:sync`. -## Phase 1 — draft (no network) +Read [`references/format.md`](references/format.md) before creating or editing +an issue. It is the single source of truth for identity, metadata, types, +labels, templates, and language rules. -1. **Read the format**: load `../use/references/issue-format.md`. -2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a - container for several issues with one business value), or `draft` (for - ideas not ready for work). If it is not obvious from the request, ask the - user (one question). -3. **Write `tmp/issues/drafts/.md`**: a metadata block carrying - `labels:` only, then `# Title`, then the type's template. +## Identity: the slug - ```markdown - --- - labels: [type/task, tech/sql, comp/appclick] - --- - # Wire sqlc into the appclick repo layer +The file name is the id and the id is a slug — `tmp/issues/wire-sqlc-appclick.md`. +It never changes, not when the title changes and not when the issue is pushed +somewhere. Tracker numbers live in a metadata field (`gitea: owner/repo#42`), +never in a file name and never in `depends:`. - ## Summary - ... - ``` +Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to +issues by id. - English imperative title with no type prefix; every section of the - template present and in order; headers English, prose Russian; `## Spec` - filled with a repo path, a URL, or the literal `none` — ask the user if you - cannot determine which. Add `## Depends on` right after `## Spec` when the - issue depends on others (one `#N` per line); omit it otherwise. - One draft file = one issue. Several related issues = several drafts. -4. **Check the format without posting** (optional, free): - ```bash - python3 ../use/scripts/issue_push.py --all --dry-run - ``` +## Scripts -## Phase 2 — push (once the plan is agreed) +All offline, all in `/scripts/`. -```bash -python3 ../use/scripts/issue_push.py --all +| Script | What it does | +|---|---| +| `issue_new.py --type T --title "…"` | create `tmp/issues/.md` from the type's template | +| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors | +| `issue_tree.py [id…]` | draw the dependency graph from `depends:` | +| `issue_index.py` | rebuild `tmp/issues/INDEX.md` | +| `issue.py` | the domain module the others import — not a command | + +``` +tmp/issues/INDEX.md table of every issue — read this first +tmp/issues/wire-sqlc-appclick.md metadata block + `# Title` + body +tmp/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only) +tmp/issues/tree-.md saved graph (issue_tree.py --write) ``` -The script validates the format (exactly one `type/*`, at most one -`severity/*`, English title, `## Summary` / `## Spec` / `## Acceptance -criteria` present), creates any missing labels — `exclusive: true` for -`type/*` and `severity/*`, canonical colors from the format doc — POSTs each -issue, prints `#N `, and **deletes the draft**. The issue lives in Gitea -now; the local copy is not a mirror and must not linger. +## Reading: grep, don't parse -Flags: `--keep` writes `tmp/issues/.md` instead of deleting, `--dry-run` -validates only, `--force` posts despite format violations (say why). +Metadata is one field per line with inline lists precisely so plain `grep` +works. `INDEX.md` first, then the files: -Report the issue URLs and the applied labels to the user. +```bash +grep -l 'labels:.*type/bug' tmp/issues/*.md # all bugs +grep -l 'origin: local' tmp/issues/*.md # never pushed anywhere +grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it +grep -A3 '## Acceptance criteria' tmp/issues/wire-*.md +grep -c '^- \[ \]' tmp/issues/wire-sqlc-appclick.md # open checkboxes +``` -## Editing an existing issue +Read whole files only for the issues the task actually needs. -Drafts only create. To bring an existing issue to the format: fetch it with -`python3 ../use/scripts/issue_get.py ` (writes `tmp/issues/.md`, -prints a compact line), restructure the body into the type's template without -losing information, then `PATCH repos/{owner}/{repo}/issues/{n}` via `tea api` -with the new title/body and ensure exactly one `type/*` label is set. Login is -always the placeholder `--login "$GITEA_LOGIN"` (see `/tea:use`). +## Creating an issue + +1. **Read the format**: [`references/format.md`](references/format.md). +2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a + container for several issues with one business value), or `draft` (an idea + not ready for work). If it is not obvious from the request, ask the user + (one question). +3. **Scaffold it:** + ```bash + python3 /scripts/issue_new.py \ + --type task --title "Wire sqlc into the appclick repo layer" \ + --label tech/sql --label comp/appclick --depends migrate-schema + ``` + English imperative title with no type prefix; `--depends` takes ids. +4. **Fill the sections** with Edit — every section of the template present and + in order, headers English, prose Russian. `## Spec` gets a repo path, a URL, + or the literal `none`; ask the user if you cannot determine which. +5. **Check it:** + ```bash + python3 /scripts/issue_check.py wire-sqlc-appclick + ``` + +One file = one issue. Several related issues = several files, linked through +`depends:`. + +The issue is now real and complete. Publishing it to Gitea is a separate +decision — `/tea:sync` — and does not change the file's status here. + +## Editing an issue + +Edit the file. Change `state:` to close it, edit `labels:`, tick checkboxes in +`## Acceptance criteria`, add ids to `depends:`. Re-run `issue_check.py` +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. + +## Dependency graph + +`depends:` is the authoritative edge list; the body's `## Depends on` section +is prose for humans. `issue_check.py` warns when they disagree. + +```bash +python3 /scripts/issue_tree.py # all roots +python3 /scripts/issue_tree.py wire-sqlc-appclick --write +``` + +A `type/feature` plus its children read as one document: draw the tree once for +the shape, then grep the files. + +## Layering rule + +This skill must keep working with `skills/sync/` deleted. Every import under +`scripts/` is stdlib, and `subprocess` is not among them: + +```bash +grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u +``` + +If you find yourself wanting a tracker concept here — an issue number, a login, +an HTTP call — it belongs in `/tea:sync`. diff --git a/skills/use/references/issue-format.md b/skills/issue/references/format.md similarity index 54% rename from skills/use/references/issue-format.md rename to skills/issue/references/format.md index 6d05e84..0609903 100644 --- a/skills/use/references/issue-format.md +++ b/skills/issue/references/format.md @@ -1,9 +1,72 @@ # Issue format -Canonical format for every issue created or edited via `tea`. Designed to be -unambiguous for both humans and LLMs: fixed English section headers in a fixed -order, verifiable acceptance criteria, one issue = one deliverable. Source -spec: the project wiki ([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)). +Canonical format for every issue in this project, whether it ever reaches a +tracker or not. Designed to be unambiguous for both humans and LLMs: fixed +English section headers in a fixed order, verifiable acceptance criteria, one +issue = one deliverable. Source spec: the project wiki +([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)). + +Nothing here depends on Gitea. How these files are mapped onto a tracker is the +sync layer's business — see `/tea:sync`. + +## Identity + +An issue is one file, `tmp/issues/.md`, and `id` is a slug: lowercase +ASCII, digits, single dashes, derived from the title. **The slug is the +identity.** It is stable for the life of the issue — a retitled issue keeps its +slug, and an issue pushed to a tracker keeps it too. Tracker numbers are a +foreign key stored in a field, never the name of anything. + +``` +tmp/issues/wire-sqlc-appclick.md +``` + +## Metadata block + +One field per line, lists inline, so plain `grep` works without a parser: + +```markdown +--- +id: wire-sqlc-appclick +state: open +labels: [type/task, tech/sql] +assignees: [naudachu] +milestone: v0.2 +depends: [migrate-schema] +origin: gitea +gitea: claude-skills/tea#42 +remote-updated: 2026-08-09T18:24:01Z +synced: 2026-08-09T18:40:00Z +url: https://git.noodles.cam/claude-skills/tea/issues/42 +--- +# Wire sqlc into the appclick repo layer + +## Summary +… +``` + +| Field | Owner | Meaning | +|---|---|---| +| `id` | domain | slug; equals the file name | +| `state` | domain | `open` or `closed` | +| `labels` | domain | see namespaces below; exactly one `type/*` | +| `assignees` | domain | logins; may be empty | +| `milestone` | domain | title, or `none` | +| `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` | +| `url`, `synced`, `remote-updated`, `comments` | sync | bookkeeping | + +Domain fields render first, in the order above; sync fields follow, sorted. + +`origin` is domain-owned on purpose: *whether* a piece of work exists anywhere +but here is a fact about the work. *Where* that is, and how to reach it, is the +sync layer's business — the domain carries `gitea:` and the rest through +load/save verbatim and never reads them. That passthrough is why one file can +represent a local issue and a synced one without a second format. + +`origin: local` is a **durable state, not a pending one.** An issue that never +leaves this machine is complete and valid. Pushing is optional and additive. ## Language rules @@ -16,8 +79,8 @@ spec: the project wiki ([Issues-Workflow](https://git.noodles.cam/claude-skills/ ## Label namespaces -Four namespaces classify an issue. Two are exclusive (Gitea enforces at most -one label from the scope), two are free-form: +Four namespaces classify an issue. Two are exclusive (at most one label from +the namespace), two are free-form: | Namespace | Exclusive | Purpose | |---|---|---| @@ -28,24 +91,19 @@ one label from the scope), two are free-form: ### `type/*` — mandatory, exactly one -| Label | Color | Meaning | -|---|---|---| -| `type/bug` | `#ee0701` | Something behaves incorrectly in existing code | -| `type/task` | `#0e8a16` | Implementation of new functionality | -| `type/refactor` | `#1d76db` | Internal restructuring: file moves, architecture; behavior must not change | -| `type/test` | `#fbca04` | Writing or fixing tests | -| `type/feature` | `#5319e7` | Container: several issues delivering one unit of business value | -| `type/draft` | `#cccccc` | Idea captured for later; not ready for work | +| Label | Meaning | +|---|---| +| `type/bug` | Something behaves incorrectly in existing code | +| `type/task` | Implementation of new functionality | +| `type/refactor` | Internal restructuring: file moves, architecture; behavior must not change | +| `type/test` | Writing or fixing tests | +| `type/feature` | Container: several issues delivering one unit of business value | +| `type/draft` | Idea captured for later; not ready for work | ### `severity/*` — at most one -| Label | Color | -|---|---| -| `severity/low` | `#c2e0c6` | -| `severity/medium` | `#fbca04` | -| `severity/high` | `#eb6420` | -| `severity/showstopper` | `#ee0701` | -| `severity/critical` | `#b60205` | +`severity/low`, `severity/medium`, `severity/high`, `severity/showstopper`, +`severity/critical`. ### `tech/*` — any number @@ -58,35 +116,38 @@ storage), `tech/obs` (grafana, loki, prometheus, alloy — observability), Components of this repo's system, e.g. `comp/appclick`. No preset list — derive from the project. -### Creating exclusive labels - -Gitea enforces exclusivity only if the label was created with -`exclusive: true`. The `tea labels create` command (as of tea 0.14.2) cannot -set that field, so missing `type/*` and `severity/*` labels MUST be created -via `tea api`: - -```bash -tea api --login "$GITEA_LOGIN" -X POST \ - -d '{"name":"type/bug","color":"#ee0701","exclusive":true,"description":"Something behaves incorrectly in existing code"}' \ - repos/{owner}/{repo}/labels -``` - -`tech/*` and `comp/*` are non-exclusive; either `tea labels create` or -`tea api` works for them. +> Label **colors** are not part of the format: a hex code is how a tracker +> paints a chip, not what an issue is. They live in `skills/sync/scripts/map.py` +> and are applied on push. ## Dependencies -An issue may explicitly depend on others. Declare that in an optional -`## Depends on` section placed right after `## Spec`, one `#N` reference per -line: +`depends:` in the metadata block is the graph, and it holds **ids**: + +```markdown +depends: [migrate-schema, add-pool-cfg] +``` + +An optional `## Depends on` section, placed right after `## Spec`, carries the +human explanation — one reference per line, with a reason where it helps: ```markdown ## Depends on -- #12 — нужна схема БД из этого issue -- #15 +- migrate-schema — нужна схема БД из этого issue +- add-pool-cfg ``` -Omit the section when there are no dependencies — never write an empty one. +The section is prose and is passed to and from a tracker unchanged; only +`depends:` is walked when the graph is computed. Keeping them consistent is on +you — `issue_check.py` warns when the section names an id that `depends:` does +not list. Omit the section when there are no dependencies; never write an empty +one. + +Draw the graph with `issue_tree.py`. The reverse direction is a grep: + +```bash +grep -ln 'depends:.*migrate-schema' tmp/issues/*.md +``` ## Shared rules @@ -99,9 +160,9 @@ Omit the section when there are no dependencies — never write an empty one. valid answer. - Acceptance criteria are `- [ ]` checkboxes; each item is an objectively checkable condition, not an aspiration. -- Code references use the `path/file.ext:line` form; related issues as `#N`. +- Code references use the `path/file.ext:line` form; related issues by id. - Screenshots are allowed but their content must be duplicated as text — an - LLM posting through `tea api` cannot read images. + LLM reading these files cannot see images. - If acceptance criteria grow past ~5 unrelated items, split the issue (or promote it to a `type/feature` container with child issues). @@ -195,9 +256,9 @@ Omit the section when there are no dependencies — never write an empty one. ## Template: `type/feature` A container: one unit of business value delivered by several child issues. -Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and link -back via `## Depends on` or the `## Issues` list here. Keep implementation -detail in the children; the feature body stays at business level. +Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and link back +via their `depends:`. Keep implementation detail in the children; the feature +body stays at business level. ```markdown ## Summary @@ -210,7 +271,7 @@ detail in the children; the feature body stays at business level. Какую проблему пользователя/системы это решает. ## Issues -- [ ] #N — краткое описание части +- [ ] wire-sqlc-appclick — краткое описание части - [ ] … ## Acceptance criteria @@ -222,8 +283,7 @@ detail in the children; the feature body stays at business level. A parking spot for ideas that are not fleshed out yet. Minimal structure, no acceptance criteria required. Before implementation starts, a draft MUST be -promoted: relabeled to a concrete type and rewritten into that type's -template. +promoted: relabeled to a concrete type and rewritten into that type's template. ```markdown ## Summary @@ -238,8 +298,9 @@ template. ## Containers beyond `type/feature` -- **Milestone** — a set of issues with an optional time bound. Manage via - `tea milestones` / `tea milestones issues`. -- **Project** — a set of issues describing one project, tracked by status - columns. Standard statuses: Backlog, ToDo, InProgress, Ready, Done. The - Gitea projects API is not exposed via `tea` subcommands — use the web UI. +- **Milestone** — a set of issues with an optional time bound. Locally it is + just the `milestone:` field; a tracker-side milestone must already exist for + a push to attach the issue to it. +- **Project** — a set of issues tracked by status columns (Backlog, ToDo, + InProgress, Ready, Done). Not represented in this format and not reachable + through the Gitea API — web UI only. diff --git a/skills/issue/scripts/issue.py b/skills/issue/scripts/issue.py new file mode 100644 index 0000000..293dba9 --- /dev/null +++ b/skills/issue/scripts/issue.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +r""" +issue.py — what an issue IS. The domain layer. + +Not a command; the module every other issue script builds on. It knows the +canonical markdown format, the label taxonomy, validation, and the dependency +graph. It knows NOTHING about any tracker: no Gitea, no `tea`, no logins, no HTTP, no +issue numbers. The layering rule is mechanically checkable — every import in +this directory is stdlib, and `subprocess` is not among them: + + grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u + +Delete skills/sync/ entirely and this layer keeps working: issues that live +only on this machine are first-class, not drafts on their way somewhere. + +Identity is a slug derived from the title, and it is the only identity the +domain has. The file name is the id: + + tmp/issues/wire-sqlc-appclick.md + + --- + id: wire-sqlc-appclick + state: open + labels: [type/task, tech/sql] + assignees: [naudachu] + milestone: v0.2 + depends: [migrate-schema] + origin: gitea + gitea: owner/repo#42 + synced: 2026-08-07T18:40:00Z + --- + # Wire sqlc into the appclick repo layer + + ## Summary + ... + +Keys above `origin:` are owned here. Everything below is written by the sync +layer; this module carries those keys through load/save verbatim and never +reads them. That passthrough is what lets one file represent both a local +issue and a synced one without the domain learning a second vocabulary. + +Every metadata field is one line and lists are inline, so plain grep works +without a parser: + + grep -l 'labels:.*type/bug' tmp/issues/*.md + grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it +""" +import os +import re + +ISSUE_ROOT = os.path.join("tmp", "issues") + +# Domain-owned metadata, in render order. Foreign keys render after these, +# sorted, so the sync layer can add fields without touching this list. +DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"] +LIST_KEYS = {"labels", "assignees", "depends"} +STATES = ("open", "closed") + +# `origin` is "does this issue exist anywhere but here" — a fact about the +# work, so it is owned here. Its value is `local` or a tracker's name; what +# that name means, and the handle that goes with it (`gitea: owner/repo#42`), +# stay foreign keys this layer carries but never reads. +LOCAL = "local" + +# type/* is mandatory and exclusive; severity/* is optional and exclusive; +# tech/* and comp/* are free-form. Colors are NOT here — a hex code is how +# Gitea paints a chip, which makes it the sync layer's business. +TYPES = { + "bug": "Something behaves incorrectly in existing code", + "task": "Implementation of new functionality", + "refactor": "Internal restructuring; behavior must not change", + "test": "Writing or fixing tests", + "feature": "Container: several issues delivering one unit of business value", + "draft": "Idea captured for later; not ready for work", +} +SEVERITIES = ("low", "medium", "high", "showstopper", "critical") +EXCLUSIVE_NS = ("type/", "severity/") + +# Sections every type must carry. type/draft is exempt from acceptance criteria. +REQUIRED_SECTIONS = ["## Summary", "## Spec"] +AC_SECTION = "## Acceptance criteria" +DEPENDS_SECTION = "## Depends on" +# Per-type sections from the templates — absence is a warning, not a stop. +EXPECTED_SECTIONS = { + "bug": ["## Steps to reproduce", "## Expected", "## Actual", "## Environment"], + "task": ["## Motivation"], + "refactor": ["## Motivation", "## Invariants"], + "test": ["## Motivation", "## Test cases"], + "feature": ["## Motivation", "## Issues"], + "draft": ["## Notes"], +} + +TITLE_PREFIX = re.compile( + r'^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)', re.I) +CYRILLIC = re.compile(r'[а-яё]', re.I) +SLUG_OK = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$') + + +# -------------------------------------------------------------------------- +# identity +# -------------------------------------------------------------------------- + +def slugify(text, maxlen=48): + """Title -> id. Titles are English by format rule, so ASCII is enough; + anything else is dropped rather than transliterated.""" + s = re.sub(r'[^a-z0-9]+', '-', (text or "").lower()).strip("-") + if len(s) > maxlen: + s = s[:maxlen].rsplit("-", 1)[0] or s[:maxlen] + return s.strip("-") or "issue" + + +def unique_id(root, base, taken=()): + """`base`, or base-2, base-3… when the slug is already used.""" + used = set(taken) | set(all_ids(root)) + if base not in used: + return base + for i in range(2, 1000): + cand = "%s-%d" % (base, i) + if cand not in used: + return cand + raise ValueError("cannot allocate an id for %r" % base) + + +# -------------------------------------------------------------------------- +# metadata block +# -------------------------------------------------------------------------- + +def parse_meta(text): + """Split a file into (meta, title, body). + + meta values are strings, or lists for the inline `[a, b]` form. title is + the first `# ` heading below the block and is stripped out of body.""" + meta, rest = {}, text + if text.startswith("---"): + end = text.find("\n---", 3) + if end != -1: + for line in text[3:end].strip().splitlines(): + if ":" not in line: + continue + k, v = line.split(":", 1) + k, v = k.strip(), v.strip() + if v.startswith("[") and v.endswith("]"): + v = [x.strip() for x in v[1:-1].split(",") if x.strip()] + elif k in LIST_KEYS: + v = [x.strip() for x in v.split(",") if x.strip()] + meta[k] = v + rest = text[end + 4:] + rest = rest.lstrip("\n") + + title = "" + m = re.match(r'^#\s+(.+?)\s*\n', rest) + if m: + title = m.group(1).strip() + rest = rest[m.end():].lstrip("\n") + return meta, title, rest + + +def render_meta(meta): + """Domain keys in DOMAIN_KEYS order, foreign keys after them, sorted. + Lists stay on one line so grep sees them whole.""" + lines = ["---"] + foreign = sorted(k for k in meta if k not in DOMAIN_KEYS) + for k in DOMAIN_KEYS + foreign: + if k not in meta: + continue + v = meta[k] + if isinstance(v, (list, tuple)): + v = "[%s]" % ", ".join(str(x) for x in v) + lines.append("%s: %s" % (k, v)) + lines.append("---") + return "\n".join(lines) + + +# -------------------------------------------------------------------------- +# the issue +# -------------------------------------------------------------------------- + +class Issue(object): + """One unit of work. `extra` holds metadata this layer does not own.""" + + def __init__(self, id="", title="", body="", state="open", labels=None, + assignees=None, milestone="", depends=None, origin=LOCAL, + extra=None): + self.id = id + self.title = title + self.body = body + self.state = state or "open" + self.labels = list(labels or []) + self.assignees = list(assignees or []) + self.milestone = milestone or "" + self.depends = list(depends or []) + self.origin = origin or LOCAL + self.extra = dict(extra or {}) + + @property + def is_local(self): + """True while this issue exists nowhere but here — a durable state, + not a pending one.""" + return self.origin == LOCAL + + # -- taxonomy views ---------------------------------------------------- + + @property + def type(self): + for l in self.labels: + if l.startswith("type/"): + return l.split("/", 1)[1] + return "" + + @property + def severity(self): + for l in self.labels: + if l.startswith("severity/"): + return l.split("/", 1)[1] + return "" + + # -- serialization ----------------------------------------------------- + + @classmethod + def from_text(cls, text, id=None): + meta, title, body = parse_meta(text) + extra = {k: v for k, v in meta.items() if k not in DOMAIN_KEYS} + + def lst(key): + v = meta.get(key) or [] + return [v] if isinstance(v, str) else list(v) + + ms = meta.get("milestone") or "" + return cls(id=id or meta.get("id") or "", + title=title, body=body.strip(), + state=meta.get("state") or "open", + labels=lst("labels"), assignees=lst("assignees"), + milestone="" if ms == "none" else ms, + depends=lst("depends"), + origin=meta.get("origin") or LOCAL, extra=extra) + + def to_text(self): + meta = dict(self.extra) + meta.update({ + "id": self.id, + "state": self.state, + "labels": self.labels, + "assignees": self.assignees, + "milestone": self.milestone or "none", + "depends": self.depends, + "origin": self.origin, + }) + body = self.body.strip() or "(no body)" + return "%s\n# %s\n\n%s\n" % (render_meta(meta), self.title, body) + + +# -------------------------------------------------------------------------- +# body sections +# -------------------------------------------------------------------------- + +def section_body(body, header): + """Text under `header`, up to the next `## ` heading.""" + out, active = [], False + for line in (body or "").splitlines(): + if line.startswith("## "): + if active: + break + active = line.strip() == header + continue + if active: + out.append(line) + return "\n".join(out).strip() + + +def body_dep_refs(body): + """Tokens referenced from `## Depends on` / `## Issues` only — never from + prose, or a graph walk would drag in half the backlog. Returns whatever was + written there (slugs, and `#N` on issues that came from a tracker).""" + out, active = [], False + for line in (body or "").splitlines(): + if line.startswith("## "): + active = line.strip() in (DEPENDS_SECTION, "## Issues") + continue + if not active: + continue + for tok in re.findall(r'#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b', line): + ref = ("#" + tok[0]) if tok[0] else tok[1] + if ref not in out: + out.append(ref) + return out + + +# -------------------------------------------------------------------------- +# validation +# -------------------------------------------------------------------------- + +def validate(issue, known_ids=None): + """Return (errors, warnings). Errors mean the issue is not well-formed in + the canonical format; warnings mean it deviates from its type template.""" + err, warn = [], [] + + if not issue.id: + err.append("no `id:` — the slug is the issue's identity") + elif not SLUG_OK.match(issue.id): + err.append("id %r is not a slug (lowercase, digits, single dashes)" % issue.id) + + if issue.state not in STATES: + err.append("state %r must be one of: %s" % (issue.state, ", ".join(STATES))) + + types = [l for l in issue.labels if l.startswith("type/")] + if len(types) != 1: + err.append("need exactly one type/* label, found %d: %s" + % (len(types), ", ".join(types) or "none")) + elif issue.type not in TYPES: + err.append("unknown type %r — known: %s" % (issue.type, ", ".join(sorted(TYPES)))) + if len([l for l in issue.labels if l.startswith("severity/")]) > 1: + err.append("at most one severity/* label") + if issue.severity and issue.severity not in SEVERITIES: + warn.append("unknown severity %r" % issue.severity) + + if not issue.title: + err.append("no `# Title` heading below the metadata block") + else: + if TITLE_PREFIX.match(issue.title): + err.append("title carries a type prefix (%r) — the type lives in the label" + % issue.title[:24]) + if CYRILLIC.search(issue.title): + err.append("title must be English, imperative mood (prose stays Russian)") + + for h in REQUIRED_SECTIONS: + if h not in issue.body: + err.append("missing section %s" % h) + if issue.type != "draft" and AC_SECTION not in issue.body: + err.append("missing section %s" % AC_SECTION) + if "## Spec" in issue.body and not section_body(issue.body, "## Spec"): + err.append("## Spec is empty — put a repo path, a URL, or the literal `none`") + + for h in EXPECTED_SECTIONS.get(issue.type, []): + if h not in issue.body: + warn.append("type/%s template usually has %s" % (issue.type, h)) + + if issue.id in issue.depends: + err.append("depends on itself") + if known_ids is not None: + for d in issue.depends: + if d not in known_ids: + warn.append("depends on %r, which is not in the store" % d) + + # `depends:` is the machine-readable graph; the body section is prose for + # humans. They drift silently unless something says so. + listed = set(issue.depends) + for ref in body_dep_refs(issue.body): + if not ref.startswith("#") and ref not in listed: + warn.append("%s mentions %r but `depends:` does not list it" + % (DEPENDS_SECTION, ref)) + + return err, warn + + +# -------------------------------------------------------------------------- +# store +# -------------------------------------------------------------------------- + +def path_of(root, id): + return os.path.join(root, "%s.md" % id) + + +def all_ids(root): + if not os.path.isdir(root): + return [] + return sorted(f[:-3] for f in os.listdir(root) + if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))) + + +def load(root, id): + with open(path_of(root, id)) as f: + return Issue.from_text(f.read(), id=id) + + +def load_all(root): + return {i: load(root, i) for i in all_ids(root)} + + +def save(root, issue): + os.makedirs(root, exist_ok=True) + p = path_of(root, issue.id) + with open(p, "w") as f: + f.write(issue.to_text()) + return p + + +# -------------------------------------------------------------------------- +# dependency graph +# -------------------------------------------------------------------------- + +def graph(issues): + """{id: [dep ids]} from the `depends:` metadata — the authoritative edge + list. Body prose is never walked.""" + return {i: list(iss.depends) for i, iss in issues.items()} + + +def dependents(issues, id): + """Who depends on `id` (the upward direction).""" + return sorted(i for i, iss in issues.items() if id in iss.depends) + + +def topo_order(ids, edges): + """Dependencies first. Cycles are broken deterministically rather than + raising: a cycle is a data problem for the caller to report, not a reason + to refuse to order the rest.""" + order, state = [], {} + + def visit(n): + if state.get(n) == "done": + return + if state.get(n) == "open": + return # cycle — leave the back edge unresolved + state[n] = "open" + for d in edges.get(n, []): + if d in edges: + visit(d) + state[n] = "done" + order.append(n) + + for n in ids: + visit(n) + return order + + +def find_cycles(edges): + """List of id lists, one per cycle found. Empty when the graph is a DAG.""" + cycles, state, stack = [], {}, [] + + def visit(n): + state[n] = "open" + stack.append(n) + for d in edges.get(n, []): + if d not in edges: + continue + if state.get(d) == "open": + cycles.append(stack[stack.index(d):] + [d]) + elif d not in state: + visit(d) + stack.pop() + state[n] = "done" + + for n in edges: + if n not in state: + visit(n) + return cycles diff --git a/skills/issue/scripts/issue_check.py b/skills/issue/scripts/issue_check.py new file mode 100644 index 0000000..9968ee1 --- /dev/null +++ b/skills/issue/scripts/issue_check.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +issue_check.py — validate issues against the canonical format. Offline. + +The same check the sync layer runs before it pushes anything, available on its +own so a local-only issue can be held to the format without a tracker being +involved. Errors mean malformed; warnings mean it deviates from its type's +template or its graph looks suspect. + + issue_check.py every issue in the store + issue_check.py wire-sqlc one issue + issue_check.py --quiet exit code only (0 clean, 1 errors) + +Format reference: ../references/format.md +""" +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import issue # noqa: E402 + + +def main(): + ap = argparse.ArgumentParser(description="Validate local issues (offline)") + ap.add_argument("ids", nargs="*", help="ids to check (default: all)") + ap.add_argument("--quiet", action="store_true", help="exit code only") + ap.add_argument("--strict", action="store_true", help="treat warnings as errors") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args() + + issues = issue.load_all(args.out) + ids = args.ids or sorted(issues) + for i in ids: + if i not in issues: + sys.exit("issue_check.py: no issue %r in %s" % (i, args.out)) + if not ids: + sys.exit("issue_check.py: store %s is empty" % args.out) + + known = set(issues) + bad = 0 + for i in ids: + err, warn = issue.validate(issues[i], known_ids=known) + if args.strict: + err, warn = err + warn, [] + if err: + bad += 1 + if args.quiet: + continue + if not err and not warn: + print("ok %s" % i) + continue + for e in err: + print("ERROR %s: %s" % (i, e)) + for w in warn: + print("warn %s: %s" % (i, w)) + + for c in issue.find_cycles(issue.graph(issues)): + bad += 1 + if not args.quiet: + print("ERROR cycle: %s" % " -> ".join(c)) + + if not args.quiet: + print("%d issue(s) checked, %d with errors" % (len(ids), bad)) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/issue/scripts/issue_index.py b/skills/issue/scripts/issue_index.py new file mode 100644 index 0000000..6a21369 --- /dev/null +++ b/skills/issue/scripts/issue_index.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +issue_index.py — rebuild tmp/issues/INDEX.md from what is on disk. Offline. + +A map of the local store, nothing else. The `origin` column is the only place +the index acknowledges that a tracker exists: `local` means the issue has never +left this machine, `gitea` means the sync layer has pushed or pulled it. Both +are ordinary issues here. + +Usage: + issue_index.py [--out tmp/issues] +""" +import argparse +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import issue # noqa: E402 + + +def cell(v): + if isinstance(v, (list, tuple)): + return ", ".join(str(x) for x in v) or "—" + v = str(v or "").strip() + return v.replace("|", "\\|") or "—" + + +def build(root): + issues = issue.load_all(root) + rows = [] + for i in sorted(issues): + iss = issues[i] + rest = [l for l in iss.labels if not l.startswith("type/")] + rows.append({ + "id": i, + "state": cell(iss.state), + "type": cell(iss.type), + "labels": cell(rest), + "title": cell(iss.title), + "milestone": cell(iss.milestone), + "depends": cell(iss.depends), + "origin": cell(iss.origin), + }) + + listing = os.listdir(root) if os.path.isdir(root) else [] + trees = sorted(f for f in listing if re.match(r'^tree-.+\.md$', f)) + + out = ["# Issue store", "", + "Every issue this project knows about. `origin: local` means it " + "exists nowhere else — a complete state, not a pending one. Any " + "other value names the tracker it also lives in; the handle is in " + "the file. Rebuild with `issue_index.py`.", ""] + if rows: + out += ["| id | state | type | labels | title | milestone | depends | origin |", + "|---|---|---|---|---|---|---|---|"] + out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s |" % ( + r["id"], r["id"], r["state"], r["type"], r["labels"], r["title"], + r["milestone"], r["depends"], r["origin"]) for r in rows] + else: + out.append("_empty_") + + if trees: + out += ["", "## Dependency trees", ""] + out += ["- [%s](%s)" % (t, t) for t in trees] + + cycles = issue.find_cycles(issue.graph(issues)) + if cycles: + out += ["", "## Dependency cycles", ""] + out += ["- %s" % " -> ".join(c) for c in cycles] + + out.append("") + path = os.path.join(root, "INDEX.md") + os.makedirs(root, exist_ok=True) + with open(path, "w") as f: + f.write("\n".join(out)) + return path, len(rows) + + +def main(): + ap = argparse.ArgumentParser(description="Rebuild the local issue index (offline)") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args() + path, n = build(args.out) + print("%s — %d issue(s)" % (path, n)) + + +if __name__ == "__main__": + main() diff --git a/skills/issue/scripts/issue_new.py b/skills/issue/scripts/issue_new.py new file mode 100644 index 0000000..8fdbed0 --- /dev/null +++ b/skills/issue/scripts/issue_new.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +issue_new.py — create an issue in the local store. Offline, always. + +The issue is real the moment this writes the file. Nothing is pending, nothing +is a draft awaiting a tracker: `origin: local` is a durable state, and pushing +it to Gitea later (see /tea:sync) is optional and additive. + + issue_new.py --type task --title "Wire sqlc into the appclick repo layer" \ + --label tech/sql --label comp/appclick + + issue_new.py --type bug --title "Fix tea-guard crash on empty settings" \ + --depends wire-sqlc-appclick --milestone v0.2 + +Writes tmp/issues/.md prefilled with the type's template, prints the +path, and rebuilds INDEX.md. Fill the sections in an editor or with Edit; run +issue_check.py when done. + +Body prose is Russian, section headers and the title are English — see +../references/format.md. +""" +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import issue # noqa: E402 +import issue_index # noqa: E402 + +SPEC = """## Spec +none +""" + +TEMPLATES = { + "bug": """## Summary +Что сломано и где проявляется, одно-два предложения. + +""" + SPEC + """ +## Steps to reproduce +1. … +2. … + +## Expected +Что должно было произойти. + +## Actual +Что происходит на самом деле: вывод команды, лог. + +## Environment +Только релевантное: версии, ОС, конфигурация. + +## Acceptance criteria +- [ ] баг не воспроизводится по шагам выше +- [ ] добавлена проверка на регрессию (если применимо) +""", + "task": """## Summary +Что нужно сделать, одно-два предложения. + +""" + SPEC + """ +## Motivation +Какую проблему пользователя/системы это решает. + +## Acceptance criteria +- [ ] проверяемое условие +- [ ] … +""", + "refactor": """## Summary +Что перестраиваем и в каких файлах (`path/file:line`). + +""" + SPEC + """ +## Motivation +Чем плохо текущее состояние: дублирование, связность, читаемость. + +## Invariants +Что НЕ должно измениться: поведение, публичные API, форматы данных. + +## Acceptance criteria +- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …) +""", + "test": """## Summary +Что покрываем тестами и где (`path/file:line`). + +""" + SPEC + """ +## Motivation +Зачем: регрессия после бага, пробел в покрытии, флаки-тест. + +## Test cases +- сценарий → ожидаемый результат +- … + +## Acceptance criteria +- [ ] перечисленные кейсы покрыты и зелёные +- [ ] тесты проходят в CI +""", + "feature": """## Summary +Бизнес-ценность одним-двумя предложениями. + +""" + SPEC + """ +## Motivation +Какую проблему пользователя/системы это решает. + +## Issues +- [ ] slug-дочернего-issue — краткое описание части +- [ ] … + +## Acceptance criteria +- [ ] все дочерние issues закрыты +- [ ] проверяемое условие уровня фичи +""", + "draft": """## Summary +Идея одним-двумя предложениями. + +""" + SPEC + """ +## Notes +Свободные заметки: что известно, открытые вопросы, варианты. +""", +} + +DEPENDS_BLOCK = """## Depends on +%s +""" + + +def with_depends(body, depends): + """Insert `## Depends on` right after `## Spec`, per the format.""" + if not depends: + return body + block = DEPENDS_BLOCK % "\n".join("- %s" % d for d in depends) + lines, out, placed = body.splitlines(True), [], False + for line in lines: + if not placed and line.startswith("## ") and not line.startswith("## Summary") \ + and not line.startswith("## Spec") and out: + out.append(block + "\n") + placed = True + out.append(line) + if not placed: + out.append("\n" + block) + return "".join(out) + + +def main(): + ap = argparse.ArgumentParser(description="Create a local issue from its type template") + ap.add_argument("--type", required=True, choices=sorted(issue.TYPES), + help="issue type (becomes the exclusive type/* label)") + ap.add_argument("--title", required=True, help="English, imperative, no type prefix") + ap.add_argument("--id", help="slug (default: derived from the title)") + ap.add_argument("--label", action="append", default=[], + help="extra label, e.g. tech/sql; repeat") + ap.add_argument("--severity", choices=list(issue.SEVERITIES), help="severity/* label") + ap.add_argument("--milestone", default="", help="milestone title") + ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat") + ap.add_argument("--depends", action="append", default=[], + help="id this issue depends on; repeat") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args() + + labels = ["type/%s" % args.type] + if args.severity: + labels.append("severity/%s" % args.severity) + labels += [l for l in args.label if l not in labels] + + id = args.id or issue.unique_id(args.out, issue.slugify(args.title)) + if args.id and not issue.SLUG_OK.match(args.id): + sys.exit("issue_new.py: --id %r is not a slug (lowercase, digits, single dashes)" + % args.id) + if os.path.exists(issue.path_of(args.out, id)): + sys.exit("issue_new.py: %s already exists" % issue.path_of(args.out, id)) + + known = set(issue.all_ids(args.out)) + for d in args.depends: + if d not in known: + sys.stderr.write("warning: depends on %r, which is not in the store yet\n" % d) + + iss = issue.Issue( + id=id, title=args.title, + body=with_depends(TEMPLATES[args.type], args.depends), + labels=labels, assignees=args.assignee, milestone=args.milestone, + depends=args.depends) + + path = issue.save(args.out, iss) + issue_index.build(args.out) + print("%s [type/%s] %s" % (path, args.type, args.title)) + print("fill the sections, then: issue_check.py %s" % id) + + +if __name__ == "__main__": + main() diff --git a/skills/issue/scripts/issue_tree.py b/skills/issue/scripts/issue_tree.py new file mode 100644 index 0000000..a3dbf2e --- /dev/null +++ b/skills/issue/scripts/issue_tree.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +issue_tree.py — draw the dependency graph of the local store. Offline. + +Edges come from the `depends:` metadata, which is the authoritative edge list; +prose in the body is never walked. Because the graph is slugs all the way down, +this works identically for issues that were never pushed anywhere. + + issue_tree.py every root (nothing depends on it) + issue_tree.py wire-sqlc-appclick one subtree + issue_tree.py --depth 2 --write + +Downwards is what this draws (what an issue depends on). The other direction is +a grep, not a flag: + + grep -ln 'depends:.*migrate-schema' tmp/issues/*.md +""" +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import issue # noqa: E402 + + +def label(id, issues, seen, edges): + iss = issues.get(id) + if not iss: + return "%s (not in the store)" % id + tail = " (see above)" if id in seen and edges.get(id) else "" + return "%s [%s] %s — %s %s.md%s" % ( + id, iss.type or "-", iss.title, iss.state, id, tail) + + +def render(roots, issues, edges, depth): + lines, seen = [], set() + + def walk(id, prefix, is_last, is_root, level): + connector = "" if is_root else ("└── " if is_last else "├── ") + lines.append(prefix + connector + label(id, issues, seen, edges)) + if id in seen or level >= depth: + return + seen.add(id) + kids = edges.get(id) or [] + child_prefix = prefix if is_root else prefix + (" " if is_last else "│ ") + for i, k in enumerate(kids): + walk(k, child_prefix, i == len(kids) - 1, False, level + 1) + + for r in roots: + if r in seen: + continue # already drawn as somebody's child — one tree, not two + walk(r, "", True, True, 0) + lines.append("") + + head = roots[0] if len(roots) == 1 else "%d root(s)" % len(roots) + out = "# Dependency tree — %s\n\n```\n%s```\n" % (head, "\n".join(lines)) + cycles = issue.find_cycles(edges) + if cycles: + out += "\n## Cycles\n\n" + "\n".join("- %s" % " -> ".join(c) for c in cycles) + "\n" + return out + + +def main(): + ap = argparse.ArgumentParser(description="Draw the local dependency graph (offline)") + ap.add_argument("ids", nargs="*", help="roots (default: issues nothing depends on)") + ap.add_argument("--depth", type=int, default=6, help="max depth (default: 6)") + ap.add_argument("--write", action="store_true", + help="also write tmp/issues/tree-.md") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args() + + issues = issue.load_all(args.out) + if not issues: + sys.exit("issue_tree.py: store %s is empty" % args.out) + edges = issue.graph(issues) + + roots = args.ids + for r in roots: + if r not in issues: + sys.exit("issue_tree.py: no issue %r in %s" % (r, args.out)) + if not roots: + depended_on = {d for deps in edges.values() for d in deps} + roots = sorted(i for i in issues if i not in depended_on) or sorted(issues) + + text = render(roots, issues, edges, args.depth) + sys.stdout.write(text) + if args.write: + slug = roots[0] if len(roots) == 1 else "all" + path = os.path.join(args.out, "tree-%s.md" % slug) + with open(path, "w") as f: + f.write(text) + print("written: %s" % path) + + +if __name__ == "__main__": + main() diff --git a/skills/sync/SKILL.md b/skills/sync/SKILL.md new file mode 100644 index 0000000..4d75604 --- /dev/null +++ b/skills/sync/SKILL.md @@ -0,0 +1,173 @@ +--- +name: sync +description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, or comment on one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network. +--- + +# /tea:sync — the bridge between the local store and Gitea + +One job: translate between `tmp/issues/.md` and Gitea's JSON, and carry the +result over the wire. Everything about **what an issue is** — format, types, +validation, the dependency graph — belongs to `/tea:issue` and is imported from +there, never redefined here. + +Direction of knowledge, and it is one-way: + +``` +skills/issue domain what an issue is offline, no tracker + ▲ + │ imports +skills/sync bridge map.py md <-> Gitea JSON, pure, no I/O + _gitea.py login, tea api, pagination, filters +``` + +`skills/issue` never imports anything from here. + +## Never read an issue through raw `tea` + +`tea issues -o json` and `tea api .../issues/` dump the full payload — +avatars, nested user objects, every comment body — into your context whether +you need it or not. Use `pull.py`: it writes flat markdown and prints a compact +index. + +## Scripts + +In `/scripts/`. None of them take `--login`: they resolve the +operator's pin from `.claude/settings.local.json` themselves, the same source +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 ` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/.md` | +| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success | +| `comment.py --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread | +| `map.py`, `_gitea.py` | the two layers the commands import — not commands | + +Key forms for ``: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo +defaults to the current directory's git remote; add `--repo owner/repo` outside +one. + +## Identity mapping + +The local id is a slug; Gitea's is a number. The pair is recorded in the issue +file itself: + +``` +origin: gitea +gitea: claude-skills/tea#42 +url: https://git.noodles.cam/claude-skills/tea/issues/42 +synced: 2026-08-09T18:40:00Z +``` + +`tmp/issues/.remote.json` indexes those fields for fast lookup. It is a cache +over the files, not a second source of truth — delete it and the next command +rebuilds it. + +A retitled issue keeps its slug: the map is keyed by number, so a pull updates +the existing file instead of creating a second one. + +## Pulling + +```bash +python3 /scripts/pull.py 42 +python3 /scripts/pull.py --milestone 6 # id or title +python3 /scripts/pull.py --label type/bug --state all +python3 /scripts/pull.py -q sqlc --limit 20 +python3 /scripts/pull.py 40 --deps # follow dependencies +``` + +Do not loop over numbers to pull a group — pass the filter. The list endpoint +carries the issue bodies, so a milestone costs **one request per 50 issues**, +not one per issue. Filters AND together; `--state` defaults to `open`; +`--limit` to 100. Keys and filters are mutually exclusive. + +**A pull overwrites the local body.** It is a fetch, not a merge — unpushed +local edits are lost. `--cached` skips issues already on disk. + +Two traps this handles for you: + +- **Gitea silently ignores an unresolvable milestone filter** and returns the + whole backlog. `pull.py` resolves the milestone first (exiting with the real + ones if it does not exist) and re-checks every returned issue locally. Never + trust a raw `tea api ...issues?milestones=X` for this. +- **Projects are not fetchable.** The projects API is not exposed (404 on + Gitea 1.26 for `repos/…/projects`, `orgs/…/projects`, `projects/{id}`). Use + milestones or labels; project columns live in the web UI only. + +After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no +extra requests. + +## Pushing + +```bash +python3 /scripts/push.py --dry-run # validate, no network +python3 /scripts/push.py # every local-only issue +python3 /scripts/push.py wire-sqlc-appclick +python3 /scripts/push.py --update wire-sqlc-appclick # PATCH +``` + +**Pushing is additive: the local file is never deleted.** It gains `gitea:`, +`url:`, `synced:`, and `origin:` flips to `gitea`. One issue, visible in two +places — not two kinds of file. + +Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`, +at most one `severity/*`, English title with no type prefix, `## Summary` / +`## Spec` / `## Acceptance criteria` present). `--force` posts anyway — say why +when you use it. + +Issues go up in topological order, dependencies first. A dependency that is +still local-only is reported, not silently dropped: the body's `## Depends on` +prose is sent verbatim either way, but the `#N` cross-link will be missing +until that issue is pushed too. + +Missing labels are created with the canonical color and, for `type/*` and +`severity/*`, `exclusive: true` — `tea labels create` cannot set that field +(tea 0.14.2), so it goes through `tea api`. Colors live in `map.py`; the names +and their meaning come from the domain taxonomy. + +A milestone must already exist in the repo — push attaches, it does not create. + +## What crosses the boundary, and what does not + +| domain | Gitea | note | +|---|---|---| +| `id` (slug) | — | local only; the tracker never sees it | +| title, body | `title`, `body` | verbatim, both directions | +| `state` | `state` | same vocabulary | +| `labels` | `labels[]` | names both ways; ids only on write | +| `assignees` | `assignees[]` | logins | +| `milestone` | `milestone.title` | resolved to an id on write | +| `depends` | — | slugs; seeded from `#N` on pull | +| — | `number`, `html_url` | lands in `gitea:` / `url:` | + +`depends:` is always slugs. The body's `## Depends on` section is human prose +and is passed through **unchanged** in both directions: a pull seeds `depends:` +from the `#N` it finds there, a push never rewrites what the author wrote. A +translator that edits prose churns the body on every round trip. + +Comments are **pull-only** in the store: `.comments.md` is written by +`pull.py --comments` and `comment.py`, and editing it by hand changes nothing +in Gitea. + +## Drift + +There is none tracked. The store is not a mirror: nothing watches Gitea, +nothing reconciles, nothing warns that a synced issue changed upstream. +`synced:` tells you how old your copy is; `remote-updated:` what the server +said at that moment. Re-pull when it matters. + +## Rich payloads for everything else + +Comments and issues are wrapped by the scripts above. For **other** entities +(pulls, releases, PATCHing something these scripts do not cover), entity +subcommands like `tea pulls create` hang on a large or formatted body — an +empty-looking positional triggers the `$EDITOR` fallback on a TTY that does not +exist, and the harness eventually kills the process (exit 144 = 128 + SIGURG on +macOS). Write the JSON payload to `$PWD/tmp/` first and POST it with +`tea api -d @file`. Procedure and endpoint table: `/tea:use`. + +## Login + +Every `tea` call made by hand must carry the literal placeholder +`--login "$GITEA_LOGIN"`; the `tea-guard` hook substitutes the operator's pin. +Set it with `/tea:auth`. Details in `/tea:use`. diff --git a/skills/sync/scripts/_gitea.py b/skills/sync/scripts/_gitea.py new file mode 100644 index 0000000..7976f56 --- /dev/null +++ b/skills/sync/scripts/_gitea.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +_gitea.py — transport. Everything that talks to Gitea, and nothing else. + +Not a command. This module knows logins, HTTP verbs, pagination, and Gitea's +query quirks. It does NOT know what an issue is: no sections, no acceptance +criteria, no type taxonomy. Payload shapes come from map.py; the domain model +lives one layer further out in skills/issue/scripts/issue.py. + +Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up +from CWD — the same file /tea:auth writes and the tea-guard hook reads. No +script here accepts a login argument: the operator's pin is the only identity +they will use. No pin -> exit with a pointer to /tea:auth. + +Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with +a local slug. It is transport bookkeeping, not domain data — the domain never +reads it, and losing it costs a re-pull, not information. +""" +import datetime +import json +import os +import subprocess +import re +import sys +import urllib.parse + +PAYLOAD_DIR = ".payload" +REMOTE_MAP = ".remote.json" + + +def die(msg, code=1): + sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg)) + sys.exit(code) + + +def warn(msg): + sys.stderr.write("warning: %s\n" % msg) + + +def now_iso(): + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# -------------------------------------------------------------------------- +# login +# -------------------------------------------------------------------------- + +def find_pin(start_dir=None): + """Walk up from start_dir; return the login from the first + .claude/settings.local.json carrying a non-empty env.GITEA_LOGIN.""" + d = os.path.abspath(start_dir or ".") + while True: + p = os.path.join(d, ".claude", "settings.local.json") + if os.path.isfile(p): + try: + with open(p) as f: + v = (json.load(f).get("env") or {}).get("GITEA_LOGIN") + if isinstance(v, str) and v.strip(): + return v.strip() + except Exception: + pass + parent = os.path.dirname(d) + if parent == d: + return None + d = parent + + +def require_login(): + login = find_pin(os.getcwd()) + if not login: + die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.") + return login + + +# -------------------------------------------------------------------------- +# api +# -------------------------------------------------------------------------- + +def api(login, endpoint, method="GET", payload=None, payload_name=None, + out_root=None, allow_fail=False): + """Call `tea api`; return parsed JSON (None on an empty body). + + payload (a dict) is written to /.payload/.json and passed + as -d @file — the file survives the call for retries and debugging. + allow_fail returns None instead of exiting when the call fails.""" + cmd = ["tea", "api", "--login", login] + if method != "GET": + cmd += ["-X", method] + if payload is not None: + pdir = os.path.join(out_root or ".", PAYLOAD_DIR) + os.makedirs(pdir, exist_ok=True) + path = os.path.join(pdir, "%s.json" % (payload_name or "request")) + with open(path, "w") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + cmd += ["-d", "@" + path] + cmd.append(endpoint) + + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + if allow_fail: + return None + die("`tea api %s %s` failed:\n%s" % (method, endpoint, (r.stderr or r.stdout).strip())) + body = r.stdout.strip() + if not body: + return None + try: + return json.loads(body) + except json.JSONDecodeError: + if allow_fail: + return None + die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500])) + + +def paginate(login, endpoint, limit=50, max_pages=40, **kw): + """GET a list endpoint page by page; return the concatenated list.""" + sep = "&" if "?" in endpoint else "?" + out = [] + for page in range(1, max_pages + 1): + batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw) + if not isinstance(batch, list) or not batch: + break + out.extend(batch) + if len(batch) < limit: + break + return out + + +def repo_base(repo=None): + """API prefix. Without --repo, let tea fill {owner}/{repo} from CWD.""" + return "repos/%s" % repo if repo else "repos/{owner}/{repo}" + + +def repo_slug(login, repo=None): + """owner/repo as a literal string — needed for remote keys, which must not + contain tea's {owner}/{repo} placeholder.""" + if repo: + return repo + got = api(login, "repos/{owner}/{repo}", allow_fail=True) + if isinstance(got, dict) and got.get("full_name"): + return got["full_name"] + die("cannot determine owner/repo from the CWD — pass --repo owner/repo") + + +def parse_key(key): + """Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / a URL.""" + key = key.strip() + m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key) + if m: + return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2)) + m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key) + if m: + return int(m.group(2)), m.group(1) + m = re.match(r'^#?(\d+)$', key) + if m: + return int(m.group(1)), None + die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key) + + +# -------------------------------------------------------------------------- +# filters +# -------------------------------------------------------------------------- + +def resolve_milestone(login, base, value): + """(id, title) for a milestone given by id or title. Exits if unknown. + + Gitea silently IGNORES an unresolvable `milestones=` filter and returns the + whole backlog, so the milestone must be resolved before it is trusted.""" + got = paginate(login, "%s/milestones?state=all" % base, limit=100) + for m in got or []: + if str(m.get("id")) == str(value) or m.get("title") == str(value): + return m["id"], m.get("title", "") + have = ", ".join("%s (id %d)" % (m.get("title", ""), m["id"]) for m in got or []) + die("no milestone %r in this repo — have: %s" % (value, have or "none")) + + +def matches(payload, milestone_id=None, labels=()): + """Client-side re-check of a server-side filter — see resolve_milestone.""" + if payload.get("pull_request"): + return False + if milestone_id is not None and (payload.get("milestone") or {}).get("id") != milestone_id: + return False + names = {l.get("name", "") for l in payload.get("labels") or []} + return all(l in names for l in labels) + + +def list_issues(login, base, state="open", labels=(), query=None, + milestone=None, limit=100): + """Filtered issue payloads. Returns (payloads, milestone_title). + + One request per page, and the payload already carries the issue bodies — a + whole milestone costs one call per 50 issues, not one per issue.""" + ms_id, ms_title = (None, None) + if milestone is not None: + ms_id, ms_title = resolve_milestone(login, base, milestone) + + params = {"state": state, "type": "issues"} + if labels: + params["labels"] = ",".join(labels) + if query: + params["q"] = query + if ms_title: + params["milestones"] = ms_title + endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params)) + + per_page = min(limit, 50) + got = paginate(login, endpoint, limit=per_page, + max_pages=max(1, -(-limit // per_page))) + got = [p for p in got if matches(p, ms_id, labels)] + return got[:limit], ms_title + + +def get_issue(login, base, number): + payload = api(login, "%s/issues/%d" % (base, number)) + if not isinstance(payload, dict) or "number" not in payload: + die("issue #%d not found" % number) + return payload + + +def get_comments(login, base, number): + return paginate(login, "%s/issues/%d/comments" % (base, number)) + + +def native_deps(login, base, number): + """Gitea's own issue-dependency links; empty when unsupported.""" + got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True) + return [i["number"] for i in got] if isinstance(got, list) else [] + + +# -------------------------------------------------------------------------- +# labels +# -------------------------------------------------------------------------- + +def ensure_labels(login, base, specs, root): + """Map label name -> id, creating what the repo is missing. + + `specs` is {name: {"color", "description", "exclusive"}} handed in by the + caller — this module does not know which namespaces are exclusive or what + they mean. Cached in /.labels.json; the cache is refreshed from the + API before anything is created.""" + cache_path = os.path.join(root, ".labels.json") + cache = {} + if os.path.isfile(cache_path): + try: + with open(cache_path) as f: + cache = json.load(f) + except Exception: + cache = {} + + if any(n not in cache for n in specs): + cache = {l["name"]: l["id"] for l in paginate(login, "%s/labels" % base, limit=100)} + + for name, spec in specs.items(): + if name in cache: + continue + payload = dict(spec, name=name) + created = api(login, "%s/labels" % base, "POST", payload, + payload_name="label-%s" % name.replace("/", "-"), out_root=root) + if not created or "id" not in created: + die("could not create label %r" % name) + cache[name] = created["id"] + sys.stderr.write("created label %s%s\n" + % (name, " (exclusive)" if spec.get("exclusive") else "")) + + os.makedirs(root, exist_ok=True) + with open(cache_path, "w") as f: + json.dump(cache, f, indent=2, sort_keys=True) + return {n: cache[n] for n in specs} + + +def resolve_milestone_id(login, base, title): + """Milestone id for a title, or None when the repo has no such milestone.""" + if not title or title == "none": + return None + for m in paginate(login, "%s/milestones?state=all" % base, limit=100) or []: + if m.get("title") == title: + return m["id"] + return None + + +# -------------------------------------------------------------------------- +# id map: remote key <-> local slug +# -------------------------------------------------------------------------- + +def map_path(root): + return os.path.join(root, REMOTE_MAP) + + +def load_map(root): + """{"owner/repo#42": "wire-sqlc-appclick"}""" + p = map_path(root) + if not os.path.isfile(p): + return {} + try: + with open(p) as f: + got = json.load(f) + return got if isinstance(got, dict) else {} + except Exception: + return {} + + +def save_map(root, m): + os.makedirs(root, exist_ok=True) + with open(map_path(root), "w") as f: + json.dump(m, f, indent=2, sort_keys=True) + + +def rebuild_map(root, issues): + """Recover the id map from the `gitea:` fields on disk. The files are the + source of truth; .remote.json is only an index over them.""" + m = {} + for id, iss in issues.items(): + key = iss.extra.get("gitea") + if key: + m[key] = id + save_map(root, m) + return m diff --git a/skills/sync/scripts/comment.py b/skills/sync/scripts/comment.py new file mode 100644 index 0000000..4d52084 --- /dev/null +++ b/skills/sync/scripts/comment.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +comment.py — post or edit a comment on a synced issue. + +The last issue operation that used to be hand-rolled (`mkdir tmp/comment`, +`jq -Rs`, `tea api -X POST`). Entity commands like `tea comment` hang on a +multi-line body — an empty-looking positional triggers the $EDITOR fallback on +a TTY that does not exist — so everything goes through `tea api` with the +payload written to a file first. + + comment.py wire-sqlc-appclick --file notes.md + comment.py wire-sqlc-appclick --body "готово, задеплоено" + comment.py wire-sqlc-appclick --file fix.md --edit 1234 + +The target is a local id, not a number: this layer resolves it through the +`gitea:` field. A local-only issue cannot be commented on — there is nothing to +comment on yet. After a successful write the comment thread is refetched into +.comments.md so the local copy is not stale. + +Comments are pull-only in the store: nothing round-trips them back, and editing +.comments.md by hand changes nothing in Gitea. + +Login: the operator's pin from .claude/settings.local.json (see /tea:auth). +""" +import argparse +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))] + +import _gitea # noqa: E402 +import issue # noqa: E402 +import map as gmap # noqa: E402 + + +def main(): + ap = argparse.ArgumentParser(description="Comment on a synced issue") + ap.add_argument("id", help="local issue id (must already be in Gitea)") + src = ap.add_mutually_exclusive_group(required=True) + src.add_argument("--file", help="markdown file holding the comment body") + src.add_argument("--body", help="comment body inline (short, single-line)") + ap.add_argument("--edit", type=int, metavar="COMMENT_ID", + help="PATCH an existing comment instead of posting a new one") + ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args() + + root = args.out + if not os.path.isfile(issue.path_of(root, args.id)): + _gitea.die("no issue %r in %s" % (args.id, root)) + iss = issue.load(root, args.id) + + number = gmap.number_of(iss) + if not number: + _gitea.die("%s is local-only (no gitea: field) — push it first" % args.id) + + if args.file: + if not os.path.isfile(args.file): + _gitea.die("no such file: %s" % args.file) + with open(args.file) as f: + body = f.read().strip() + else: + body = args.body.strip() + if not body: + _gitea.die("empty comment body") + + login = _gitea.require_login() + base = _gitea.repo_base(args.repo) + + if args.edit: + got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH", + {"body": body}, payload_name="comment-%d" % args.edit, + out_root=root) + verb = "edited" + else: + got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST", + {"body": body}, payload_name="comment-%s" % args.id, + out_root=root) + verb = "posted" + if not isinstance(got, dict) or "id" not in got: + _gitea.die("%s failed, unexpected response" % verb) + + comments = _gitea.get_comments(login, base, number) + cpath = os.path.join(root, "%s.comments.md" % args.id) + if comments: + with open(cpath, "w") as f: + f.write(gmap.render_comments(comments)) + elif os.path.isfile(cpath): + os.remove(cpath) + + print("%s comment %s on %s (#%d) %s" + % (verb, got["id"], args.id, number, got.get("html_url", ""))) + print("thread: %s (%d comment(s))" % (cpath, len(comments))) + + +if __name__ == "__main__": + main() diff --git a/skills/sync/scripts/map.py b/skills/sync/scripts/map.py new file mode 100644 index 0000000..f02e073 --- /dev/null +++ b/skills/sync/scripts/map.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +map.py — md <-> Gitea JSON. The whole translation, and only the translation. + +Pure functions: no network, no filesystem, no argparse. Give it a payload and +it hands back a domain Issue; give it an Issue and it hands back a request +body. That purity is the point — it can be reasoned about and tested without a +Gitea anywhere, and it is the single file to open when the two representations +disagree. + +Direction of knowledge: this module imports the domain (issue.py) and is +imported by the transport's callers. The domain never imports this. + +What crosses the boundary, and what does not: + + domain Gitea note + ---------------------------------------------------------------------- + id (slug) — local only; the tracker never sees it + title, body title, body verbatim, both ways + state state open/closed, same vocabulary + labels labels[] names both ways; ids only on write + assignees assignees[] logins + milestone milestone.title resolved to an id on write + depends — slugs; #N is translated at the edge + — number, html_url lands in extra as gitea:/url: + +`depends:` is the authoritative graph and is always slugs. The body's +`## Depends on` section is human prose and is passed through UNCHANGED in both +directions: a pull seeds `depends:` from the `#N` it finds there, and a push +never rewrites what the author wrote. Deliberate — a translator that edits +prose churns the body on every round trip. +""" +import os +import sys + +sys.path.insert(0, os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "issue", "scripts"))) +import issue # noqa: E402 + +# How the taxonomy is painted in Gitea's UI. A hex code says nothing about what +# an issue IS, which is exactly why it lives here and not in the domain. +LABEL_COLORS = { + "type/bug": "#ee0701", + "type/task": "#0e8a16", + "type/refactor": "#1d76db", + "type/test": "#fbca04", + "type/feature": "#5319e7", + "type/draft": "#cccccc", + "severity/low": "#c2e0c6", + "severity/medium": "#fbca04", + "severity/high": "#eb6420", + "severity/showstopper": "#ee0701", + "severity/critical": "#b60205", +} +DEFAULT_COLOR = "#ededed" + +# What this bridge writes into the domain's `origin:` field. The domain records +# that an issue exists somewhere else; only this module knows where. +ORIGIN = "gitea" + + +def label_specs(names): + """{name: {color, description, exclusive}} for the transport to create. + + Exclusivity and meaning come from the domain taxonomy; only the color is + decided here. `tea labels create` cannot set `exclusive` (as of 0.14.2), + which is why these go through the API.""" + out = {} + for name in names: + desc = "" + if name.startswith("type/"): + desc = issue.TYPES.get(name.split("/", 1)[1], "") + out[name] = { + "color": LABEL_COLORS.get(name, DEFAULT_COLOR), + "description": desc, + "exclusive": name.startswith(issue.EXCLUSIVE_NS), + } + return out + + +def remote_key(repo, number): + """Stable cross-repo handle: owner/repo#42.""" + return "%s#%d" % (repo, int(number)) + + +def parse_remote_key(key): + repo, _, num = (key or "").rpartition("#") + return (repo, int(num)) if repo and num.isdigit() else (None, None) + + +# -------------------------------------------------------------------------- +# Gitea -> domain +# -------------------------------------------------------------------------- + +def numbers_in_body(body): + """`#N` referenced from the body's dependency sections, as ints. Used only + to seed `depends:` on the first pull.""" + return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")] + + +def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None): + """Build a domain Issue from a Gitea issue payload. + + id_for_number maps a Gitea number to a local slug — dependencies whose + target has not been pulled yet are dropped from `depends:` (the body still + names them, so nothing is lost) rather than invented.""" + body = (payload.get("body") or "").strip() + id_for_number = id_for_number or {} + + numbers = list(numbers_in_body(body)) + for n in extra_numbers: + if n not in numbers: + numbers.append(n) + depends, unresolved = [], [] + for n in numbers: + slug = id_for_number.get(n) + if slug and slug != id and slug not in depends: + depends.append(slug) + elif not slug: + unresolved.append(n) + + extra = { + "gitea": remote_key(repo, payload["number"]), + "url": payload.get("html_url", ""), + "synced": synced or "", + } + if payload.get("updated_at"): + extra["remote-updated"] = payload["updated_at"] + if payload.get("comments"): + extra["comments"] = payload["comments"] + + iss = issue.Issue( + id=id, + title=payload.get("title", ""), + body=body, + state=payload.get("state") or "open", + labels=[l.get("name", "") for l in payload.get("labels") or []], + assignees=[a.get("login", "") for a in payload.get("assignees") or []], + milestone=(payload.get("milestone") or {}).get("title") or "", + depends=depends, + origin=ORIGIN, + extra=extra) + return iss, unresolved + + +def render_comments(comments): + """Comment thread as flat markdown. Read-only: nothing writes it back.""" + out = [] + for c in comments: + out.append("## comment %s — %s — %s" % ( + c.get("id"), (c.get("user") or {}).get("login", ""), + (c.get("created_at") or "")[:10])) + out.append("") + out.append((c.get("body") or "(empty)").strip()) + out.append("") + return "\n".join(out) + + +# -------------------------------------------------------------------------- +# domain -> Gitea +# -------------------------------------------------------------------------- + +def to_payload(iss, label_ids=None, milestone_id=None, include_state=False): + """Request body for POST /issues or PATCH /issues/{n}. + + The body is sent verbatim — see the module docstring on why slugs in + `## Depends on` are not rewritten to `#N`.""" + payload = {"title": iss.title, "body": iss.body.strip()} + if label_ids is not None: + payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids] + if iss.assignees: + payload["assignees"] = list(iss.assignees) + if milestone_id is not None: + payload["milestone"] = milestone_id + if include_state: + payload["state"] = iss.state + return payload + + +def apply_remote(iss, payload, repo, synced): + """Stamp the sync-owned fields onto an issue after a successful write. + Mutates and returns it; `origin` is the one domain field this touches.""" + iss.origin = ORIGIN + iss.extra["gitea"] = remote_key(repo, payload["number"]) + iss.extra["url"] = payload.get("html_url", "") + iss.extra["synced"] = synced + if payload.get("updated_at"): + iss.extra["remote-updated"] = payload["updated_at"] + return iss + + +def number_of(iss): + """Gitea number for an already-synced issue, or None.""" + _repo, n = parse_remote_key(iss.extra.get("gitea", "")) + return n diff --git a/skills/sync/scripts/pull.py b/skills/sync/scripts/pull.py new file mode 100644 index 0000000..0857676 --- /dev/null +++ b/skills/sync/scripts/pull.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +pull.py — Gitea issues -> the local store. + +Writes flat markdown the domain layer owns and prints a compact index; the raw +API payload never reaches the conversation. An issue already in the store keeps +its slug even when its title changes on the server — identity is the local id, +matched through tmp/issues/.remote.json (and recoverable from the `gitea:` +fields if that file is lost). + +Two ways to name what to pull: + + pull.py 42 [17 …] by key: 42 | #42 | owner/repo#42 | URL + pull.py --milestone 6 by filter: whole milestone in ONE request + pull.py --label type/bug --state all + pull.py -q sqlc --limit 20 + +Filter mode costs one request per 50 issues — the list payload already carries +the bodies. Gitea silently ignores an unresolvable `milestones=` filter and +returns the whole backlog, so the milestone is resolved up front and every +issue is re-checked locally. Projects are NOT filterable: the projects API is +not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI. + +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 + --repo owner/repo default: auto-detect from the CWD git remote + +Pulling overwrites the local body: it is a fetch, not a merge. Local edits you +have not pushed are lost. Draw the graph afterwards with the domain's own +issue_tree.py — it needs no network. + +Login: the operator's pin from .claude/settings.local.json (see /tea:auth). +""" +import argparse +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))] + +import _gitea # noqa: E402 +import issue # noqa: E402 +import issue_index # noqa: E402 +import map as gmap # noqa: E402 + + +def id_for(payload, store_ids, remote_map, repo, root): + """Existing slug for this remote issue, or a fresh unique one. A retitled + issue keeps the slug it was first pulled under — the map is by number.""" + got = remote_map.get(gmap.remote_key(repo, payload["number"])) + if got: + return got + return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids) + + +def main(): + ap = argparse.ArgumentParser(description="Pull Gitea issues into the local store") + ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL") + ap.add_argument("--milestone", help="pull a whole milestone (id or title)") + ap.add_argument("--label", action="append", default=[], + help="filter by label; repeat for AND") + ap.add_argument("-q", "--query", help="search text in title/body") + ap.add_argument("--state", default="open", choices=["open", "closed", "all"], + help="filter mode only (default: open)") + ap.add_argument("--limit", type=int, default=100, help="filter mode 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)") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args() + + filtered = bool(args.milestone or args.label or args.query) + if args.keys and filtered: + _gitea.die("pass issue keys OR filters, not both") + if not args.keys and not filtered: + _gitea.die("nothing to pull: pass issue keys, or --milestone / --label / -q") + + root = args.out + login = _gitea.require_login() + + # ---- which repo ------------------------------------------------------ + repo_arg = args.repo + if not repo_arg and args.keys: + repos = {_gitea.parse_key(k)[1] for k in args.keys} - {None} + if len(repos) > 1: + _gitea.die("all keys must belong to one repo, got: %s" % ", ".join(sorted(repos))) + repo_arg = repos.pop() if repos else None + base = _gitea.repo_base(repo_arg) + repo = _gitea.repo_slug(login, repo_arg) + + issues = issue.load_all(root) + remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues) + store_ids = set(issues) + number_of_id = {gmap.parse_remote_key(k)[1]: v for k, v in remote_map.items() + if gmap.parse_remote_key(k)[0] == repo} + + written, skipped, pending = [], [], [] + + # ---- seeds ----------------------------------------------------------- + if filtered: + payloads, ms_title = _gitea.list_issues( + login, base, state=args.state, labels=args.label, query=args.query, + milestone=args.milestone, limit=args.limit) + if not payloads: + _gitea.die("no issues match that filter") + what = [] + if args.milestone: + what.append("milestone %s" % ms_title) + what += ["label %s" % l for l in args.label] + if args.query: + what.append("q=%r" % args.query) + sys.stderr.write("%d issue(s) match %s (%s)\n" + % (len(payloads), " + ".join(what), args.state)) + queue = [(p, 0) for p in payloads] + seen_numbers = {p["number"] for p in payloads} + else: + numbers = [_gitea.parse_key(k)[0] for k in args.keys] + queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers] + seen_numbers = set(numbers) + + 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) + store_ids.add(id) + number_of_id[number] = id + + if args.cached and os.path.isfile(issue.path_of(root, id)): + skipped.append(id) + else: + extra = _gitea.native_deps(login, base, number) if args.deps else [] + iss, unresolved = gmap.from_api(payload, id, repo, + id_for_number=number_of_id, + extra_numbers=extra, + synced=_gitea.now_iso()) + issue.save(root, iss) + remote_map[gmap.remote_key(repo, number)] = id + written.append(id) + pending.append((id, unresolved)) + + if args.deps and depth < args.depth: + child_numbers = (gmap.numbers_in_body(payload.get("body") or "") + + _gitea.native_deps(login, base, number)) + for n in child_numbers: + if n in seen_numbers: + continue + seen_numbers.add(n) + queue.append((_gitea.get_issue(login, base, n), depth + 1)) + + # ---- second pass: dependencies that were not yet known on first write -- + for id, unresolved in pending: + newly = [number_of_id[n] for n in unresolved + if n in number_of_id and number_of_id[n] != id] + if not newly: + continue + iss = issue.load(root, id) + for slug in newly: + if slug not in iss.depends: + iss.depends.append(slug) + issue.save(root, iss) + + 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. + for id in sorted(set(written) | set(skipped)): + iss = issue.load(root, id) + 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) + print("index: %s" % index_path) + if args.deps: + print("graph: run issue_tree.py (offline) to draw it") + + +if __name__ == "__main__": + main() diff --git a/skills/sync/scripts/push.py b/skills/sync/scripts/push.py new file mode 100644 index 0000000..a1920e8 --- /dev/null +++ b/skills/sync/scripts/push.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +push.py — local store -> Gitea. + +Pushing is additive. The local file is never deleted and never moves: it gains +`gitea:`, `url:` and `synced:`, and `origin:` flips from `local` to `gitea`. +One issue, two places it is visible — not two kinds of file. A local-only issue +is a finished state, not a step on the way to a tracker. + + push.py every local-only issue, dependencies first + push.py wire-sqlc-appclick one issue + push.py --update PATCH issues that are already in Gitea + push.py --dry-run validate only, no network + +Before anything is sent, each issue is validated against the canonical format +by the domain layer (exactly one type/*, English title with no type prefix, +`## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts +anyway; say why when you use it. + +Dependencies are pushed in topological order so a parent is created after the +issues it depends on. A dependency that is still local-only is reported, not +silently dropped — the body's `## Depends on` prose is sent verbatim either +way, so nothing is lost, but the `#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. + +Login: the operator's pin from .claude/settings.local.json (see /tea:auth). +""" +import argparse +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))] + +import _gitea # noqa: E402 +import issue # noqa: E402 +import issue_index # noqa: E402 +import map as gmap # noqa: E402 + + +def select(issues, ids, update): + """Which issues to send, and refuse the ambiguous combinations.""" + if ids: + missing = [i for i in ids if i not in issues] + if missing: + _gitea.die("no such issue(s) in the store: %s" % ", ".join(missing)) + chosen = list(ids) + else: + chosen = sorted(i for i in issues + if update or not issues[i].extra.get("gitea")) + if not chosen: + _gitea.die("nothing to push: every issue in the store is already in Gitea " + "(use --update to PATCH them, or issue_new.py to make one)") + if not update: + already = [i for i in chosen if issues[i].extra.get("gitea")] + if already: + _gitea.die("already in Gitea: %s — pass --update to PATCH them" + % ", ".join(already)) + return chosen + + +def main(): + ap = argparse.ArgumentParser(description="Push local issues to Gitea") + ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)") + ap.add_argument("--update", action="store_true", + help="PATCH issues that already carry a gitea: field") + ap.add_argument("--dry-run", action="store_true", help="validate only, no network") + ap.add_argument("--force", action="store_true", help="push despite format violations") + ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args() + + root = args.out + issues = issue.load_all(root) + if not issues: + _gitea.die("store %s is empty — create an issue with issue_new.py first" % root) + + chosen = select(issues, args.ids, args.update) + + # ---- validate (domain layer, no network) ----------------------------- + known = set(issues) + blocked = False + for id in chosen: + err, warn = issue.validate(issues[id], known_ids=known) + for w in warn: + _gitea.warn("%s: %s" % (id, w)) + for e in err: + sys.stderr.write("%s: %s\n" % (id, e)) + if err: + blocked = True + if blocked and not args.force: + _gitea.die("format violations (see above); --force overrides") + + # ---- dependencies first ---------------------------------------------- + edges = {i: [d for d in issues[i].depends if d in issues] for i in chosen} + order = [i for i in issue.topo_order(chosen, edges) if i in set(chosen)] + for c in issue.find_cycles(edges): + _gitea.warn("dependency cycle: %s" % " -> ".join(c)) + + if args.dry_run: + for id in order: + iss = issues[id] + print("ok %s [type/%s] %s (%s)" + % (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels")) + print("%d issue(s) would be %s" % (len(order), "updated" if args.update else "created")) + return + + login = _gitea.require_login() + base = _gitea.repo_base(args.repo) + repo = _gitea.repo_slug(login, args.repo) + + wanted = sorted({l for id in order for l in issues[id].labels}) + label_ids = _gitea.ensure_labels(login, base, gmap.label_specs(wanted), root) \ + if wanted else {} + + milestone_ids = {} + remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues) + + for id in order: + iss = issues[id] + + unsynced = [d for d in iss.depends + if d in issues and not issues[d].extra.get("gitea") + and d not in order] + if unsynced: + _gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea" + % (id, ", ".join(unsynced))) + + ms_id = None + if iss.milestone: + if iss.milestone not in milestone_ids: + milestone_ids[iss.milestone] = _gitea.resolve_milestone_id( + login, base, iss.milestone) + ms_id = milestone_ids[iss.milestone] + if ms_id is None: + _gitea.warn("%s: milestone %r does not exist in %s — not set" + % (id, iss.milestone, repo)) + + number = gmap.number_of(iss) + if number: + payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True) + got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH", payload, + payload_name="issue-%s" % id, out_root=root) + verb = "updated" + else: + payload = gmap.to_payload(iss, label_ids, ms_id) + got = _gitea.api(login, "%s/issues" % base, "POST", payload, + payload_name="issue-%s" % id, out_root=root) + verb = "created" + if not isinstance(got, dict) or "number" not in got: + _gitea.die("%s: %s failed, unexpected response" % (id, verb)) + number = got["number"] + + # Gitea occasionally drops labels on create — re-apply rather than + # trust the echo. + applied = {l.get("name", "") for l in got.get("labels") or []} + missing = [l for l in iss.labels if l in label_ids and l not in applied] + if missing: + _gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT", + {"labels": [label_ids[l] for l in iss.labels if l in label_ids]}, + payload_name="labels-%s" % id, out_root=root) + _gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing))) + + gmap.apply_remote(iss, got, repo, _gitea.now_iso()) + issue.save(root, iss) + remote_map[gmap.remote_key(repo, number)] = id + print("%s %s #%d %s" % (verb, id, number, got.get("html_url", ""))) + + _gitea.save_map(root, remote_map) + path, n = issue_index.build(root) + print("index: %s — %d issue(s)" % (path, n)) + + +if __name__ == "__main__": + main() diff --git a/skills/sync/scripts/remote.py b/skills/sync/scripts/remote.py new file mode 100644 index 0000000..9b26605 --- /dev/null +++ b/skills/sync/scripts/remote.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +remote.py — what exists in Gitea, one line each. + +Discovery only: prints to stdout and writes nothing. The local store is a +store, not a search-results folder, so a listing never lands in it. Pick the +numbers here, then pull them. + + #42 open type/task, tech/sql Wire sqlc into the repo layer + └─ local: wire-sqlc-appclick + +The second line appears when the issue is already in the local store, so it is +obvious what a pull would refresh versus what it would add. + +Usage: + remote.py [--state open|closed|all] [--label L]… [-q TEXT] + [--milestone M] [--limit N] [--repo owner/repo] + +Login: the operator's pin from .claude/settings.local.json (see /tea:auth). +""" +import argparse +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))] + +import _gitea # noqa: E402 +import issue # noqa: E402 +import map as gmap # noqa: E402 + + +def main(): + ap = argparse.ArgumentParser(description="List Gitea issues (stdout only, no files)") + ap.add_argument("--state", default="open", choices=["open", "closed", "all"]) + ap.add_argument("--label", action="append", default=[], + help="filter by label; repeat for AND") + ap.add_argument("-q", "--query", help="search text in title/body") + ap.add_argument("--milestone", help="milestone id or title") + ap.add_argument("--limit", type=int, default=30) + ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)") + ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)") + args = ap.parse_args() + + login = _gitea.require_login() + base = _gitea.repo_base(args.repo) + payloads, ms_title = _gitea.list_issues( + login, base, state=args.state, labels=args.label, query=args.query, + milestone=args.milestone, limit=args.limit) + + remote_map = _gitea.load_map(args.out) + repo = _gitea.repo_slug(login, args.repo) if remote_map else None + + for p in payloads: + labels = ", ".join(l.get("name", "") for l in p.get("labels") or []) or "-" + print("#%-5d %-7s %-38s %s" % (p["number"], p.get("state", ""), + labels[:38], p.get("title", ""))) + local = remote_map.get(gmap.remote_key(repo, p["number"])) if repo else None + if local: + print("%13s└─ local: %s" % ("", local)) + + scope = " in milestone %s" % ms_title if ms_title else "" + hint = ("--milestone %s" % args.milestone) if args.milestone else "" + print("%d issue(s)%s — pull them with: pull.py %s" % (len(payloads), scope, hint)) + + +if __name__ == "__main__": + main() diff --git a/skills/use/SKILL.md b/skills/use/SKILL.md index 3d37738..a3b95b9 100644 --- a/skills/use/SKILL.md +++ b/skills/use/SKILL.md @@ -1,6 +1,6 @@ --- name: use -description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, issues, pulls, releases, actions, or other Gitea entities, to look up the right `tea` command and flags. Always write the login as the literal placeholder --login "$GITEA_LOGIN" — the tea-guard hook substitutes the operator-pinned login; set it with /tea:auth. +description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, pulls, releases, milestones, labels, actions, webhooks, or other Gitea entities, to look up the right `tea` command and flags. Always write the login as the literal placeholder --login "$GITEA_LOGIN" — the tea-guard hook substitutes the operator-pinned login; set it with /tea:auth. Issues are NOT handled here: use /tea:issue to work on them and /tea:sync to move them to and from Gitea. --- # /tea:use — tea CLI reference @@ -9,6 +9,19 @@ Reference material for the `tea` CLI (Gitea's official command-line client). Use these docs to look up commands, flags, filters, and output fields before running `tea` via Bash. +## Issues are somewhere else + +Do **not** reach for `tea issues` or `tea api .../issues/...` to read or create +an issue. Two skills own that, and they keep the payload out of your context: + +| Skill | Scope | +|---|---| +| `/tea:issue` | issues as units of work — create, read, grep, validate, dependency graph. Offline. | +| `/tea:sync` | moving issues between the local store and Gitea — pull, push, comment. | + +This skill covers everything else Gitea has: pulls, releases, milestones, +labels, repos, branches, actions, webhooks, notifications, times. + ## Login: always write the placeholder, never a name (enforced) Every `tea` invocation that touches Gitea MUST carry the login as the **literal @@ -33,12 +46,11 @@ The pin takes effect immediately — no restart. Only `tea logins list` and ## How to use -1. Identify the entity in the request: issues, pulls, labels, milestones, - releases, times, repos, branches, actions, webhooks, comments, - notifications, etc. +1. Identify the entity in the request: pulls, labels, milestones, releases, + times, repos, branches, actions, webhooks, notifications, etc. 2. Find the matching command in the index below. 3. Run it via Bash with the placeholder login, e.g. - `tea issues list --login "$GITEA_LOGIN" --repo owner/repo --state open`. + `tea pulls list --login "$GITEA_LOGIN" --repo owner/repo --state open`. (The hook rewrites `"$GITEA_LOGIN"` to the operator-pinned login.) `tea` auto-detects owner/repo from `$PWD` inside a git repo; otherwise pass @@ -46,117 +58,6 @@ The pin takes effect immediately — no restart. Only `tea logins list` and per-project by the operator (see `/tea:auth`) and injected by the guard. Config lives in `$XDG_CONFIG_HOME/tea`. -## Issues: work on local files, not on live `tea` calls - -Never run `tea issues -o json` or `tea api .../issues/` to read an -issue — the full JSON payload (avatars, nested user objects, every comment -body) lands in your context whether you need it or not. Use the scripts in -`/scripts/`. They pull issues into a flat, greppable cache -under `$PWD/tmp/issues/` and print only a compact index. - -This is a **cache, not a mirror**: nothing tracks drift, nothing syncs back. -Refetch when you need current data; the `fetched:` field tells you the age. - -| Script | Network | What it does | -|---|---|---| -| `issue_get.py ` or `issue_get.py --milestone M \| --label L \| -q TEXT` | yes | fetch issue(s) → `tmp/issues/.md` (+ `.comments.md`, `tree-.md`) | -| `issue_list.py [--state] [--label] [--milestone] [-q TEXT]` | yes | discovery: one line per issue to stdout, writes nothing | -| `issue_push.py \|--all [--keep] [--dry-run]` | yes | validate a draft, create labels, POST the issue, delete the draft | -| `issue_index.py` | no | rebuild `tmp/issues/INDEX.md` (auto after get/push) | - -``` -tmp/issues/INDEX.md table of everything cached — read this first -tmp/issues/42.md metadata block + `# Title` + body -tmp/issues/42.comments.md comments (only with --comments) -tmp/issues/tree-40.md dependency map (only with --deps) -tmp/issues/drafts/.md issues not yet created in Gitea -``` - -Key forms for ``: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo -defaults to the current directory's git remote (add `--repo owner/repo` -outside one). No `--login` on any script call: they resolve the operator's pin -from `.claude/settings.local.json` themselves — same source as the tea-guard -hook. No pin → exit with a pointer to `/tea:auth`. - -### Fetching a whole set: milestone, label, search - -Do not loop `issue_get.py` over numbers to pull a group — pass the filter. The -list endpoint carries the issue bodies, so a milestone costs **one request per -50 issues**, not one per issue: - -```bash -python3 /scripts/issue_get.py --milestone 6 # id or title -python3 /scripts/issue_get.py --milestone v0.2 --deps -python3 /scripts/issue_get.py --label type/bug --label comp/hooks --state all -python3 /scripts/issue_get.py -q sqlc --limit 20 -``` - -Filters AND together; `--state` defaults to `open`; `--limit` defaults to 100. -Keys and filters are mutually exclusive. `--comments` stays single-issue — -loop over the numbers when a whole thread set is needed. - -Two traps this handles for you: - -- **Gitea silently ignores an unresolvable milestone filter** and returns the - whole backlog. The script resolves the milestone first (exits listing the - real ones if it does not exist) and re-checks every returned issue locally. - Never trust a raw `tea api ...issues?milestones=X` call for this. -- **Projects are not fetchable.** The projects API is not exposed (404 on - Gitea 1.26 for `repos/…/projects`, `orgs/…/projects`, `projects/{id}`). - Use milestones or labels; project columns live in the web UI only. - -After a filtered fetch, `INDEX.md` carries a `milestone` column, and the cache -is greppable by it: `grep -l 'milestone: v0.2' tmp/issues/*.md`. - -### Working a feature as one document - -`--deps` walks the dependency graph **downwards** — the structured -`## Depends on` and `## Issues` sections plus Gitea's native dependencies. -Prose `#N` mentions are ignored on purpose, or the walk would drag in half the -backlog. Comments are not fetched during a walk (loop over the numbers if you -need them). - -```bash -python3 /scripts/issue_get.py 40 --deps # feature + children -``` - -Read `tree-40.md` once for the shape (a filtered fetch writes one forest, -`tree-.md`), then grep the files as one document: - -```bash -grep -ln 'depends:.*#42' tmp/issues/*.md # who depends on #42 (upwards) -grep -l 'labels:.*type/bug' tmp/issues/*.md # all cached bugs -grep -A3 '## Acceptance criteria' tmp/issues/4*.md -grep -c '^- \[ \]' tmp/issues/42.md # open checkboxes -``` - -Metadata is written one field per line with inline lists (`labels: [a, b]`) -precisely so plain grep works without a parser. - -### Creating issues: draft locally, push once - -During planning write drafts to `tmp/issues/drafts/.md` — no network, no -`tea` call. A draft is the metadata block with `labels:` only, plus the -canonical body: - -```markdown ---- -labels: [type/task, tech/sql] ---- -# Wire sqlc into the appclick repo layer - -## Summary -... -``` - -When the plan is agreed, `issue_push.py` validates the format (exactly one -`type/*`, English title without a type prefix, `## Summary` / `## Spec` / -`## Acceptance criteria` present), creates missing labels with the right -colors and exclusivity, POSTs, prints the URL and **deletes the draft** — the -issue lives in Gitea now. `--keep` writes `tmp/issues/.md` instead; -`--dry-run` validates without touching the network. Guided procedure: -`/tea:issue`. - ## Index - [tea CLI overview](references/tea/index.md) — global flags, common options, output formats @@ -164,15 +65,14 @@ issue lives in Gitea now. `--keep` writes `tmp/issues/.md` instead; - [HELPERS](references/tea/helpers.md) — open, notifications, clone, api - [MISC](references/tea/misc.md) — whoami, admin - [SETUP](references/tea/setup.md) — logins, logout, ssh-keys -- [ISSUE FORMAT](references/issue-format.md) — canonical issue format: label - namespaces (`type/*`, `severity/*` exclusive; `tech/*`, `comp/*` free), - types `bug|task|refactor|test|feature|draft`, templates, dependencies, - title and language rules. MANDATORY whenever creating or editing an issue; - the `/tea:issue` skill is the guided procedure for it. + +The canonical issue format moved to +[`../issue/references/format.md`](../issue/references/format.md) — it describes +local files, not `tea` commands. ## Rich payloads — write to `$PWD/tmp/` first, then `tea api` -Entity subcommands (`tea comment`, `tea issues create`, `tea pulls create`, …) +Entity subcommands (`tea comment`, `tea pulls create`, `tea releases create`, …) are built for humans at a TTY. With a large or formatted body they can hang silently — an empty-looking positional arg triggers `$EDITOR` fallback, or a scope/confirm prompt waits on a TTY that doesn't exist. The harness eventually @@ -182,30 +82,29 @@ kills the process (e.g. exit 144 = 128 + SIGURG on macOS). fences / backticks / pipes / tables), bypass entity commands. Save the full request payload to `$PWD/tmp/` first, then POST via `tea api`. -Issue creation is already wrapped: use `issue_push.py` (above) instead of -hand-rolling the JSON. The procedure below covers everything else — comments, -pulls, releases, and PATCHes to existing issues. +Issues and issue comments are already wrapped — use `/tea:sync` rather than +hand-rolling their JSON. The procedure below covers everything else. ### Procedure 1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is - `comment`, `issue`, `pull`, `release`, etc. + `pull`, `release`, etc. 2. Write the **complete request body as JSON** to `$PWD/tmp/{kind}/.json`. One file = one request. Use a quoted heredoc to avoid shell expansion: ```bash - mkdir -p tmp/comment - cat > tmp/comment/issue-60.json <<'EOF' - {"body": "## Heading\n\nMulti-line markdown with `code`, | tables |, and ```fences```."} + mkdir -p tmp/release + cat > tmp/release/v0-2-0.json <<'EOF' + {"tag_name": "v0.2.0", "name": "v0.2.0", "body": "## Changes\n\nMulti-line markdown with `code`."} EOF ``` Newlines inside the body must be encoded as `\n` in the JSON string. If composing programmatically, pipe through - `jq -Rs '{body: .}' < body.md > tmp/comment/issue-60.json`. + `jq -Rs '{body: .}' < body.md > tmp/release/v0-2-0.json`. 3. POST with `tea api`, passing the file with `-d @`: ```bash tea api --login "$GITEA_LOGIN" \ - -X POST -d @tmp/comment/issue-60.json \ - repos/{owner}/{repo}/issues/60/comments + -X POST -d @tmp/release/v0-2-0.json \ + repos/{owner}/{repo}/releases ``` 4. Keep the file. `tmp/` should be gitignored; the saved payload is useful for retries, edits (`PATCH`), and debugging failed posts. @@ -214,12 +113,12 @@ pulls, releases, and PATCHes to existing issues. | Action | Method + endpoint | |---|---| -| Comment on issue/PR | `POST repos/{owner}/{repo}/issues/{n}/comments` | -| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` | -| Create issue | `POST repos/{owner}/{repo}/issues` | -| Edit issue/PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` | | Create PR | `POST repos/{owner}/{repo}/pulls` | +| Edit PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` | +| Comment on a PR | `POST repos/{owner}/{repo}/issues/{n}/comments` | +| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` | | Create release | `POST repos/{owner}/{repo}/releases` | +| Create milestone | `POST repos/{owner}/{repo}/milestones` | Short single-line bodies (e.g. `tea comment 42 "lgtm" --login "$GITEA_LOGIN"`) are still fine via entity commands. Always the placeholder, never a login name. diff --git a/skills/use/scripts/_tea.py b/skills/use/scripts/_tea.py deleted file mode 100755 index f9a6e43..0000000 --- a/skills/use/scripts/_tea.py +++ /dev/null @@ -1,406 +0,0 @@ -#!/usr/bin/env python3 -""" -_tea.py — shared helpers for the issue scripts (get / push / list / index). - -Not a command. Holds the three things every script needs: the operator's -pinned login, a `tea api` wrapper, and the grep-friendly on-disk issue format. - -On-disk format (tmp/issues/.md) — every metadata field is ONE line so that -plain grep works without a parser: - - --- - number: 42 - state: open - labels: [type/task, tech/sql] - assignees: [naudachu] - milestone: v0.2 - depends: [#12, #15] - comments: 3 - url: https://host/owner/repo/issues/42 - updated: 2026-08-05T11:20:00Z - fetched: 2026-08-07T18:40:00Z - --- - # Title in English, imperative - - ## Summary - ... - -Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up -from CWD — the same file /tea:auth writes and the tea-guard hook reads. The -scripts never accept a login argument: the operator's pin is the only identity -they will use. No pin -> exit with a pointer to /tea:auth. -""" -import datetime -import json -import os -import re -import subprocess -import sys -import urllib.parse - -ISSUE_ROOT = os.path.join("tmp", "issues") -DRAFT_DIR = "drafts" -LABEL_CACHE = ".labels.json" -PAYLOAD_DIR = ".payload" - -# Metadata keys in the order they are rendered. Keep them single-line. -META_ORDER = ["number", "state", "labels", "assignees", "milestone", - "depends", "comments", "url", "updated", "fetched"] - -# Canonical colors + descriptions from references/issue-format.md. -EXCLUSIVE_NS = ("type/", "severity/") -KNOWN_LABELS = { - "type/bug": ("#ee0701", "Something behaves incorrectly in existing code"), - "type/task": ("#0e8a16", "Implementation of new functionality"), - "type/refactor": ("#1d76db", "Internal restructuring; behavior must not change"), - "type/test": ("#fbca04", "Writing or fixing tests"), - "type/feature": ("#5319e7", "Container: several issues delivering one unit of business value"), - "type/draft": ("#cccccc", "Idea captured for later; not ready for work"), - "severity/low": ("#c2e0c6", ""), - "severity/medium": ("#fbca04", ""), - "severity/high": ("#eb6420", ""), - "severity/showstopper": ("#ee0701", ""), - "severity/critical": ("#b60205", ""), -} -DEFAULT_COLOR = "#ededed" - - -def die(msg, code=1): - sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg)) - sys.exit(code) - - -def warn(msg): - sys.stderr.write("warning: %s\n" % msg) - - -# -------------------------------------------------------------------------- -# login + api -# -------------------------------------------------------------------------- - -def find_pin(start_dir=None): - """Walk up from start_dir; return login from the first - .claude/settings.local.json carrying a non-empty env.GITEA_LOGIN.""" - d = os.path.abspath(start_dir or ".") - while True: - p = os.path.join(d, ".claude", "settings.local.json") - if os.path.isfile(p): - try: - with open(p) as f: - v = (json.load(f).get("env") or {}).get("GITEA_LOGIN") - if isinstance(v, str) and v.strip(): - return v.strip() - except Exception: - pass - parent = os.path.dirname(d) - if parent == d: - return None - d = parent - - -def require_login(): - login = find_pin(os.getcwd()) - if not login: - die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.") - return login - - -def tea_api(login, endpoint, method="GET", payload=None, payload_name=None, - out_root=ISSUE_ROOT, allow_fail=False): - """Call `tea api`; return parsed JSON (None on empty body). - - payload (a dict) is written to /.payload/.json and - passed as -d @file — the file survives the call for retries and debugging. - allow_fail returns None instead of exiting when the call fails.""" - cmd = ["tea", "api", "--login", login] - if method != "GET": - cmd += ["-X", method] - if payload is not None: - pdir = os.path.join(out_root, PAYLOAD_DIR) - os.makedirs(pdir, exist_ok=True) - path = os.path.join(pdir, "%s.json" % (payload_name or "request")) - with open(path, "w") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - cmd += ["-d", "@" + path] - cmd.append(endpoint) - - r = subprocess.run(cmd, capture_output=True, text=True) - if r.returncode != 0: - if allow_fail: - return None - die("`tea api %s %s` failed:\n%s" % (method, endpoint, (r.stderr or r.stdout).strip())) - body = r.stdout.strip() - if not body: - return None - try: - return json.loads(body) - except json.JSONDecodeError: - if allow_fail: - return None - die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500])) - - -def paginate(login, endpoint, limit=50, max_pages=40, **kw): - """GET a list endpoint page by page; return the concatenated list.""" - sep = "&" if "?" in endpoint else "?" - out = [] - for page in range(1, max_pages + 1): - batch = tea_api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw) - if not isinstance(batch, list) or not batch: - break - out.extend(batch) - if len(batch) < limit: - break - return out - - -def repo_base(repo=None): - """API prefix. Without --repo, let tea fill {owner}/{repo} from CWD.""" - return "repos/%s" % repo if repo else "repos/{owner}/{repo}" - - -def parse_key(key): - """Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / URL.""" - key = key.strip() - m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key) - if m: - return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2)) - m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key) - if m: - return int(m.group(2)), m.group(1) - m = re.match(r'^#?(\d+)$', key) - if m: - return int(m.group(1)), None - die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key) - - -def now_iso(): - return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -# -------------------------------------------------------------------------- -# filters -# -------------------------------------------------------------------------- - -def resolve_milestone(login, base, value): - """(id, title) for a milestone given by id or title. Exits if unknown. - - Gitea silently IGNORES an unresolvable `milestones=` filter and returns the - whole backlog, so the milestone must be resolved before it is trusted.""" - got = paginate(login, "%s/milestones?state=all" % base, limit=100) - for m in got or []: - if str(m.get("id")) == str(value) or m.get("title") == str(value): - return m["id"], m.get("title", "") - have = ", ".join("%s (id %d)" % (m.get("title", ""), m["id"]) for m in got or []) - die("no milestone %r in this repo — have: %s" % (value, have or "none")) - - -def issue_matches(iss, milestone_id=None, labels=()): - """Client-side re-check of a server-side filter — see resolve_milestone.""" - if iss.get("pull_request"): - return False - if milestone_id is not None and (iss.get("milestone") or {}).get("id") != milestone_id: - return False - names = {l.get("name", "") for l in iss.get("labels") or []} - return all(l in names for l in labels) - - -def list_issues(login, base, state="open", labels=(), query=None, - milestone=None, limit=100): - """Filtered issue list. Returns (issues, milestone_title). - - One request per page, and the payload already carries issue bodies — a - whole milestone costs one call, not one per issue.""" - ms_id, ms_title = (None, None) - if milestone is not None: - ms_id, ms_title = resolve_milestone(login, base, milestone) - - params = {"state": state, "type": "issues"} - if labels: - params["labels"] = ",".join(labels) - if query: - params["q"] = query - if ms_title: - params["milestones"] = ms_title - endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params)) - - got = paginate(login, endpoint, limit=min(limit, 50), - max_pages=max(1, -(-limit // min(limit, 50)))) - got = [i for i in got if issue_matches(i, ms_id, labels)] - return got[:limit], ms_title - - -# -------------------------------------------------------------------------- -# on-disk format -# -------------------------------------------------------------------------- - -def render_meta(meta): - """Metadata block; lists inline on one line so grep sees them whole.""" - lines = ["---"] - for k in META_ORDER: - if k not in meta: - continue - v = meta[k] - if isinstance(v, (list, tuple)): - v = "[%s]" % ", ".join(str(x) for x in v) - lines.append("%s: %s" % (k, v)) - lines.append("---") - return "\n".join(lines) - - -def parse_meta(text): - """Split a local issue/draft file into (meta, title, body). - - meta values are strings, or lists for the `[a, b]` inline form. title is - the first `# ` heading below the block (stripped out of body).""" - meta, rest = {}, text - if text.startswith("---"): - end = text.find("\n---", 3) - if end != -1: - for line in text[3:end].strip().splitlines(): - if ":" not in line: - continue - k, v = line.split(":", 1) - k, v = k.strip(), v.strip() - if v.startswith("[") and v.endswith("]"): - v = [x.strip() for x in v[1:-1].split(",") if x.strip()] - meta[k] = v - rest = text[end + 4:] - rest = rest.lstrip("\n") - - title = "" - m = re.match(r'^#\s+(.+?)\s*\n', rest) - if m: - title = m.group(1).strip() - rest = rest[m.end():].lstrip("\n") - return meta, title, rest - - -def issue_meta(iss, comments=None): - return { - "number": iss["number"], - "state": iss.get("state", ""), - "labels": [l.get("name", "") for l in iss.get("labels") or []], - "assignees": [a.get("login", "") for a in iss.get("assignees") or []], - "milestone": (iss.get("milestone") or {}).get("title") or "none", - "depends": ["#%d" % n for n in deps_of(iss)], - "comments": iss.get("comments", 0) if comments is None else len(comments), - "url": iss.get("html_url", ""), - "updated": iss.get("updated_at", ""), - "fetched": now_iso(), - } - - -def render_issue(iss, extra_deps=()): - meta = issue_meta(iss) - for n in extra_deps: - ref = "#%d" % n - if ref not in meta["depends"]: - meta["depends"].append(ref) - body = (iss.get("body") or "").strip() or "(no body)" - return "%s\n# %s\n\n%s\n" % (render_meta(meta), iss.get("title", ""), body) - - -def render_comments(number, comments): - head = render_meta({"number": number, "comments": len(comments), "fetched": now_iso()}) - out = [head, ""] - for c in comments: - out.append("## comment %d — %s — %s" % ( - c["id"], (c.get("user") or {}).get("login", ""), (c.get("created_at") or "")[:10])) - out.append("") - out.append((c.get("body") or "(empty)").strip()) - out.append("") - return "\n".join(out) - - -DEP_SECTIONS = ("## Depends on", "## Issues") - - -def deps_from_body(body): - """Issue numbers referenced from the structured `## Depends on` / - `## Issues` sections only — never from prose, or a --deps walk would drag - in half the backlog.""" - out, active = [], False - for line in (body or "").splitlines(): - if line.startswith("## "): - active = line.strip() in DEP_SECTIONS - continue - if active: - out.extend(int(n) for n in re.findall(r'#(\d+)', line)) - seen, uniq = set(), [] - for n in out: - if n not in seen: - seen.add(n) - uniq.append(n) - return uniq - - -def deps_of(iss): - return deps_from_body(iss.get("body") or "") - - -# -------------------------------------------------------------------------- -# paths -# -------------------------------------------------------------------------- - -def issue_path(root, n): - return os.path.join(root, "%d.md" % n) - - -def comments_path(root, n): - return os.path.join(root, "%d.comments.md" % n) - - -def tree_path(root, slug): - return os.path.join(root, "tree-%s.md" % slug) - - -def write_file(path, text): - os.makedirs(os.path.dirname(path) or ".", exist_ok=True) - with open(path, "w") as f: - f.write(text) - return path - - -def read_file(path): - with open(path) as f: - return f.read() - - -# -------------------------------------------------------------------------- -# labels -# -------------------------------------------------------------------------- - -def load_label_ids(login, base, root, names): - """Map label name -> id for every name in `names`, creating what the repo - is missing. Cached in /.labels.json; the cache is refreshed from the - API before anything is created.""" - cache_path = os.path.join(root, LABEL_CACHE) - cache = {} - if os.path.isfile(cache_path): - try: - cache = json.load(open(cache_path)) - except Exception: - cache = {} - - if any(n not in cache for n in names): - cache = {l["name"]: l["id"] - for l in paginate(login, "%s/labels" % base, limit=100)} - - for name in names: - if name in cache: - continue - color, desc = KNOWN_LABELS.get(name, (DEFAULT_COLOR, "")) - payload = {"name": name, "color": color, "description": desc, - "exclusive": name.startswith(EXCLUSIVE_NS)} - created = tea_api(login, "%s/labels" % base, "POST", payload, - payload_name="label-%s" % name.replace("/", "-"), - out_root=root) - if not created or "id" not in created: - die("could not create label %r" % name) - cache[name] = created["id"] - sys.stderr.write("created label %s%s\n" % - (name, " (exclusive)" if payload["exclusive"] else "")) - - write_file(cache_path, json.dumps(cache, indent=2, sort_keys=True)) - return {n: cache[n] for n in names} diff --git a/skills/use/scripts/issue_get.py b/skills/use/scripts/issue_get.py deleted file mode 100755 index c4511b3..0000000 --- a/skills/use/scripts/issue_get.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python3 -""" -issue_get.py — pull Gitea issues into the local grep cache under tmp/issues/. - -Token-saving fetcher: instead of dumping raw API JSON into the conversation it -writes flat, grep-friendly markdown and prints a compact index. Read only the -files the task needs. - - tmp/issues/.md metadata block + `# Title` + body - tmp/issues/.comments.md comments (only with --comments) - tmp/issues/tree-.md dependency map (only with --deps) - tmp/issues/INDEX.md table of everything cached (auto-rebuilt) - -Two ways to name what to fetch: - - issue_get.py 42 [17 …] by key: 42 | #42 | owner/repo#42 | URL - issue_get.py --milestone 6 by filter: whole milestone in ONE request - issue_get.py --label type/bug --state all - issue_get.py -q sqlc --limit 20 - -Filter mode costs one request per 50 issues — the list payload already carries -the bodies. Gitea silently ignores an unresolvable `milestones=` filter and -returns the whole backlog, so the milestone is resolved up front and every -issue is re-checked locally. Projects are NOT filterable: the projects API is -not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI. - -Other flags: - --deps [--depth N] walk dependencies downwards and write the tree map - --comments also fetch comments (single issue only) - --cached skip issues already on disk instead of refetching - --repo owner/repo default: auto-detect from the CWD git remote - ---deps follows the structured `## Depends on` / `## Issues` sections plus -Gitea's native issue dependencies. Prose `#N` mentions are ignored on purpose. -Who depends on ME is a grep, not a flag: - - grep -ln 'depends:.*#42' tmp/issues/*.md - -Login: the operator's pin from .claude/settings.local.json (see /tea:auth). -""" -import argparse -import os -import re -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import issue_index # noqa: E402 -from _tea import (ISSUE_ROOT, comments_path, deps_of, die, issue_path, # noqa: E402 - list_issues, paginate, parse_key, parse_meta, read_file, - render_comments, render_issue, repo_base, require_login, - tea_api, tree_path, write_file) - - -def native_deps(login, base, n): - """Gitea's own issue dependencies (may be unsupported -> empty).""" - got = tea_api(login, "%s/issues/%d/dependencies" % (base, n), allow_fail=True) - return [i["number"] for i in got] if isinstance(got, list) else [] - - -def store(login, base, iss, root, with_native): - """Write one issue to the cache; return its dependency numbers.""" - n = iss["number"] - extra = native_deps(login, base, n) if with_native else [] - write_file(issue_path(root, n), render_issue(iss, extra)) - return list(dict.fromkeys(deps_of(iss) + extra)) - - -def fetch_issue(login, base, n): - iss = tea_api(login, "%s/issues/%d" % (base, n)) - if not isinstance(iss, dict) or "number" not in iss: - die("issue #%d not found" % n) - return iss - - -def fetch_comments(login, base, n, root): - comments = paginate(login, "%s/issues/%d/comments" % (base, n)) - if comments: - return write_file(comments_path(root, n), render_comments(n, comments)) - if os.path.isfile(comments_path(root, n)): - os.remove(comments_path(root, n)) # stale file from an earlier fetch - return None - - -def cached_issue(root, n): - """(title, state, labels, deps) from an already-fetched file, or None.""" - path = issue_path(root, n) - if not os.path.isfile(path): - return None - meta, title, _body = parse_meta(read_file(path)) - deps = meta.get("depends") or [] - if isinstance(deps, str): - deps = [deps] - labels = meta.get("labels") or [] - if isinstance(labels, str): - labels = [labels] - return {"title": title, "state": meta.get("state", ""), "labels": labels, - "deps": [int(d.lstrip("#")) for d in deps if d.lstrip("#").isdigit()]} - - -def summary(iss): - return {"title": iss.get("title", ""), "state": iss.get("state", ""), - "labels": [l.get("name", "") for l in iss.get("labels") or []]} - - -def type_of(labels): - for l in labels: - if l.startswith("type/"): - return l.split("/", 1)[1] - return "-" - - -def render_tree(roots, nodes, edges): - """ASCII map of the walked graph; repeated nodes collapse to (see above).""" - lines, seen = [], set() - - def label(n): - s = nodes.get(n) - if not s: - return "#%d (not fetched — beyond --depth)" % n - tail = " (see above)" if n in seen and edges.get(n) else "" - return "#%d [%s] %s — %s %d.md%s" % ( - n, type_of(s["labels"]), s["title"], s["state"], n, tail) - - def walk(n, prefix, is_last, is_root): - connector = "" if is_root else ("└── " if is_last else "├── ") - lines.append(prefix + connector + label(n)) - if n in seen: - return - seen.add(n) - kids = edges.get(n) or [] - child_prefix = prefix if is_root else prefix + (" " if is_last else "│ ") - for i, k in enumerate(kids): - walk(k, child_prefix, i == len(kids) - 1, False) - - for r in roots: - if r in seen: - continue # already shown as somebody's child — one tree, not two - walk(r, "", True, True) - lines.append("") - title = "#%d" % roots[0] if len(roots) == 1 else "%d issues" % len(roots) - return "# Dependency tree for %s\n\n```\n%s```\n" % (title, "\n".join(lines)) - - -def slugify(s): - return re.sub(r'[^a-z0-9]+', '-', str(s).lower()).strip("-") or "filter" - - -def main(): - ap = argparse.ArgumentParser(description="Fetch Gitea issues into tmp/issues/") - ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL") - ap.add_argument("--milestone", help="fetch a whole milestone (id or title)") - ap.add_argument("--label", action="append", default=[], help="filter by label; repeat for AND") - ap.add_argument("-q", "--query", help="search text in title/body") - ap.add_argument("--state", default="open", choices=["open", "closed", "all"], - help="filter mode only (default: open)") - ap.add_argument("--limit", type=int, default=100, help="filter mode cap (default: 100)") - ap.add_argument("--deps", action="store_true", help="walk dependencies downwards") - ap.add_argument("--depth", type=int, default=3, help="max walk 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)") - ap.add_argument("--out", default=ISSUE_ROOT, help="cache root (default: tmp/issues)") - args = ap.parse_args() - - filtered = bool(args.milestone or args.label or args.query) - if args.keys and filtered: - die("pass issue keys OR filters, not both") - if not args.keys and not filtered: - die("nothing to fetch: pass issue keys, or --milestone / --label / -q") - - root, login = args.out, require_login() - nodes, edges, fetched, cached_hits = {}, {}, [], [] - - # ---- seeds ----------------------------------------------------------- - if filtered: - base = repo_base(args.repo) - seeds_iss, ms_title = list_issues( - login, base, state=args.state, labels=args.label, query=args.query, - milestone=args.milestone, limit=args.limit) - if not seeds_iss: - die("no issues match that filter") - what = [] - if args.milestone: - what.append("milestone %s" % ms_title) - what += ["label %s" % l for l in args.label] - if args.query: - what.append("q=%r" % args.query) - slug = slugify(ms_title or (args.label[0] if args.label else args.query)) - sys.stderr.write("%d issue(s) match %s (%s)\n" - % (len(seeds_iss), " + ".join(what), args.state)) - else: - repos = {parse_key(k)[1] for k in args.keys} - {None} - if len(repos) > 1: - die("all keys must belong to one repo, got: %s" % ", ".join(sorted(repos))) - base = repo_base(args.repo or (repos.pop() if repos else None)) - seeds_iss = None # fetched below, one by one - seeds_n = [parse_key(k)[0] for k in args.keys] - slug = str(seeds_n[0]) if len(seeds_n) == 1 else "-".join(str(n) for n in seeds_n[:4]) - - if args.comments and ((seeds_iss and len(seeds_iss) > 1) or - (seeds_iss is None and len(args.keys) > 1)): - die("--comments works on a single issue; loop over the numbers instead") - - if seeds_iss is not None: - seeds_n = [] - for iss in seeds_iss: - n = iss["number"] - seeds_n.append(n) - hit = cached_issue(root, n) if args.cached else None - if hit: - nodes[n], edges[n] = hit, hit["deps"] - cached_hits.append(n) - else: - nodes[n] = summary(iss) - edges[n] = store(login, base, iss, root, args.deps) - fetched.append(n) - - # ---- walk ------------------------------------------------------------ - queue = [(n, 0) for n in seeds_n] - visited = set(nodes) - while queue: - n, depth = queue.pop(0) - if n not in visited: - visited.add(n) - hit = cached_issue(root, n) if args.cached else None - if hit: - nodes[n], edges[n] = hit, hit["deps"] - cached_hits.append(n) - else: - iss = fetch_issue(login, base, n) - nodes[n] = summary(iss) - edges[n] = store(login, base, iss, root, args.deps) - fetched.append(n) - if args.deps and depth < args.depth: - queue.extend((d, depth + 1) for d in edges.get(n, []) if d not in visited) - - cpath = None - if args.comments: - cpath = fetch_comments(login, base, seeds_n[0], root) - - tpath = write_file(tree_path(root, slug), render_tree(seeds_n, nodes, edges)) \ - if args.deps else None - index_path, _ = issue_index.build(root) - - # Compact output — the only thing that lands in the model's context. - for n in sorted(nodes): - s = nodes[n] - print("#%d [%s] %s — %s %s%s" % ( - n, ", ".join(s["labels"]) or "no labels", s["title"], s["state"], - issue_path(root, n), " (cached)" if n in cached_hits else "")) - if cpath: - print("comments: %s" % cpath) - if tpath: - print("tree: %s" % tpath) - print("index: %s" % index_path) - - -if __name__ == "__main__": - main() diff --git a/skills/use/scripts/issue_index.py b/skills/use/scripts/issue_index.py deleted file mode 100755 index 44ace44..0000000 --- a/skills/use/scripts/issue_index.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -""" -issue_index.py — rebuild tmp/issues/INDEX.md from what is on disk. No network. - -The index is a map of the cache, nothing else: issues that were never fetched -do not appear. issue_get.py and issue_push.py call it automatically; run it by -hand only after deleting files. - -Usage: - issue_index.py [--out tmp/issues] -""" -import argparse -import os -import re -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _tea import ISSUE_ROOT, parse_meta, read_file, write_file # noqa: E402 - - -def cell(v): - if isinstance(v, list): - return ", ".join(v) or "—" - v = (v or "").strip() - return v.replace("|", "\\|") or "—" - - -def build(root): - rows = [] - for name in sorted(os.listdir(root)) if os.path.isdir(root) else []: - m = re.match(r'^(\d+)\.md$', name) - if not m: - continue - n = int(m.group(1)) - meta, title, _ = parse_meta(read_file(os.path.join(root, name))) - labels = meta.get("labels") or [] - if isinstance(labels, str): - labels = [labels] - types = [l for l in labels if l.startswith("type/")] - rest = [l for l in labels if not l.startswith("type/")] - has_comments = os.path.isfile(os.path.join(root, "%d.comments.md" % n)) - rows.append({ - "n": n, - "state": cell(meta.get("state")), - "type": cell(types[0].split("/", 1)[1] if types else ""), - "labels": cell(rest), - "title": cell(title), - "milestone": cell(meta.get("milestone")), - "depends": cell(meta.get("depends")), - "comments": ("[%s](%d.comments.md)" % (cell(meta.get("comments")), n) - if has_comments else "—"), - "fetched": cell(meta.get("fetched"))[:10], - }) - rows.sort(key=lambda r: r["n"]) - - trees = sorted(f for f in (os.listdir(root) if os.path.isdir(root) else []) - if re.match(r'^tree-\d+\.md$', f)) - - out = ["# Issue cache", "", - "Local cache of fetched issues — not a mirror. Refresh with " - "`issue_get.py `; issues absent here were never fetched.", ""] - if rows: - out += ["| # | state | type | labels | title | milestone | depends | comments | fetched |", - "|---|---|---|---|---|---|---|---|---|"] - out += ["| [#%d](%d.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % ( - r["n"], r["n"], r["state"], r["type"], r["labels"], r["title"], - r["milestone"], r["depends"], r["comments"], r["fetched"]) for r in rows] - else: - out.append("_empty_") - if trees: - out += ["", "## Dependency trees", ""] - out += ["- [%s](%s)" % (t, t) for t in trees] - - drafts_dir = os.path.join(root, "drafts") - drafts = sorted(f for f in (os.listdir(drafts_dir) if os.path.isdir(drafts_dir) else []) - if f.endswith(".md")) - if drafts: - out += ["", "## Drafts (not yet pushed)", ""] - out += ["- [drafts/%s](drafts/%s)" % (d, d) for d in drafts] - - out.append("") - return write_file(os.path.join(root, "INDEX.md"), "\n".join(out)), len(rows) - - -def main(): - ap = argparse.ArgumentParser(description="Rebuild the issue cache index (no network)") - ap.add_argument("--out", default=ISSUE_ROOT, help="cache root (default: tmp/issues)") - args = ap.parse_args() - path, n = build(args.out) - print("%s — %d issue(s)" % (path, n)) - - -if __name__ == "__main__": - main() diff --git a/skills/use/scripts/issue_list.py b/skills/use/scripts/issue_list.py deleted file mode 100755 index a755bd7..0000000 --- a/skills/use/scripts/issue_list.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -""" -issue_list.py — discovery: which issue numbers exist, one line each. - -Prints to stdout and writes nothing: INDEX.md is a map of the local cache, and -this command deliberately does not pollute it. Use it to pick numbers, then -fetch them with issue_get.py. - - #42 open type/task, tech/sql Wire sqlc into the repo layer - -Usage: - issue_list.py [--state open|closed|all] [--label L]… [-q TEXT] - [--milestone M] [--limit N] [--page N] [--repo owner/repo] - -Login: the operator's pin from .claude/settings.local.json (see /tea:auth). -""" -import argparse -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _tea import list_issues, repo_base, require_login # noqa: E402 - - -def main(): - ap = argparse.ArgumentParser(description="List Gitea issues (stdout only, no files)") - ap.add_argument("--state", default="open", choices=["open", "closed", "all"]) - ap.add_argument("--label", action="append", default=[], - help="filter by label; repeat for AND") - ap.add_argument("-q", "--query", help="search text in title/body") - ap.add_argument("--milestone", help="milestone id or title") - ap.add_argument("--limit", type=int, default=30) - ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)") - args = ap.parse_args() - - login = require_login() - got, ms_title = list_issues(login, repo_base(args.repo), state=args.state, - labels=args.label, query=args.query, - milestone=args.milestone, limit=args.limit) - for iss in got: - labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "-" - print("#%-5d %-7s %-38s %s" % (iss["number"], iss.get("state", ""), - labels[:38], iss.get("title", ""))) - scope = " in milestone %s" % ms_title if ms_title else "" - print("%d issue(s)%s — fetch them with: issue_get.py %s" - % (len(got), scope, - ("--milestone %s" % args.milestone) if args.milestone else "")) - - -if __name__ == "__main__": - main() diff --git a/skills/use/scripts/issue_push.py b/skills/use/scripts/issue_push.py deleted file mode 100755 index 7a5ce0a..0000000 --- a/skills/use/scripts/issue_push.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -""" -issue_push.py — create Gitea issues from local drafts, then drop the drafts. - -A draft is a plain markdown file under tmp/issues/drafts/ written during -planning, with no network involved: - - --- - labels: [type/task, tech/sql] - --- - # Wire sqlc into the appclick repo layer - - ## Summary - ... - -This script does what /tea:issue used to do by hand: validate the canonical -format, create any missing labels (exclusive for type/* and severity/*), POST -the issue, print its URL, and delete the draft — the issue now lives in Gitea, -the local copy is not a mirror and must not linger. --keep turns the draft into -a cache file (tmp/issues/.md) instead of deleting it. - -Usage: - issue_push.py [ …] [--keep] [--dry-run] [--force] - issue_push.py --all [--keep] [--dry-run] [--force] - issue_push.py --all --repo owner/repo --out DIR - -Format reference: ../references/issue-format.md -Login: the operator's pin from .claude/settings.local.json (see /tea:auth). -""" -import argparse -import glob -import os -import re -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import issue_index # noqa: E402 -from _tea import (DRAFT_DIR, ISSUE_ROOT, die, issue_path, load_label_ids, # noqa: E402 - parse_meta, read_file, render_issue, repo_base, - require_login, tea_api, warn, write_file) - -# Sections every type must carry; acceptance criteria is waived for drafts. -REQUIRED = ["## Summary", "## Spec"] -AC = "## Acceptance criteria" -# Per-type sections from the templates — missing ones are a warning, not a stop. -EXPECTED = { - "bug": ["## Steps to reproduce", "## Expected", "## Actual", "## Environment"], - "task": ["## Motivation"], - "refactor": ["## Motivation", "## Invariants"], - "test": ["## Motivation", "## Test cases"], - "feature": ["## Motivation", "## Issues"], - "draft": ["## Notes"], -} -TITLE_PREFIX = re.compile(r'^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)', - re.I) -CYRILLIC = re.compile(r'[а-яё]', re.I) - - -def section_body(body, header): - """Text under `header` up to the next `## ` heading.""" - out, active = [], False - for line in body.splitlines(): - if line.startswith("## "): - if active: - break - active = line.strip() == header - continue - if active: - out.append(line) - return "\n".join(out).strip() - - -def validate(path, force): - """Return (title, body, labels, type). Exits on a hard format violation.""" - meta, title, body = parse_meta(read_file(path)) - err = [] - - if "number" in meta: - err.append("draft carries `number:` — this script only creates issues; " - "edit existing ones with `tea api -X PATCH`") - - labels = meta.get("labels") or [] - if isinstance(labels, str): - labels = [l.strip() for l in labels.split(",") if l.strip()] - types = [l for l in labels if l.startswith("type/")] - if len(types) != 1: - err.append("need exactly one type/* label, found %d: %s" - % (len(types), ", ".join(types) or "none")) - if len([l for l in labels if l.startswith("severity/")]) > 1: - err.append("at most one severity/* label") - kind = types[0].split("/", 1)[1] if types else "" - - if not title: - err.append("no `# Title` heading below the metadata block") - else: - if TITLE_PREFIX.match(title): - err.append("title carries a type prefix (%r) — the type lives in the label" - % title[:24]) - if CYRILLIC.search(title): - err.append("title must be English, imperative mood (prose stays Russian)") - - for h in REQUIRED: - if h not in body: - err.append("missing section %s" % h) - if kind != "draft" and AC not in body: - err.append("missing section %s" % AC) - if "## Spec" in body and not section_body(body, "## Spec"): - err.append("## Spec is empty — put a repo path, a URL, or the literal `none`") - - if err: - for e in err: - sys.stderr.write("%s: %s\n" % (path, e)) - if not force: - die("%s: format violations (see above); --force overrides" % path) - - for h in EXPECTED.get(kind, []): - if h not in body: - warn("%s: type/%s template usually has %s" % (path, kind, h)) - - return title, body.strip(), labels, kind - - -def main(): - ap = argparse.ArgumentParser(description="Create Gitea issues from tmp/issues/drafts/") - ap.add_argument("drafts", nargs="*", help="draft markdown files") - ap.add_argument("--all", action="store_true", help="push every draft in the drafts dir") - ap.add_argument("--keep", action="store_true", - help="keep the issue locally as tmp/issues/.md instead of deleting") - ap.add_argument("--dry-run", action="store_true", help="validate only, no network") - ap.add_argument("--force", action="store_true", help="post despite format violations") - ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)") - ap.add_argument("--out", default=ISSUE_ROOT, help="cache root (default: tmp/issues)") - args = ap.parse_args() - - root = args.out - paths = list(args.drafts) - if args.all: - paths += sorted(glob.glob(os.path.join(root, DRAFT_DIR, "*.md"))) - paths = list(dict.fromkeys(paths)) - if not paths: - die("no drafts given (pass files or --all; drafts live in %s/)" - % os.path.join(root, DRAFT_DIR)) - for p in paths: - if not os.path.isfile(p): - die("no such draft: %s" % p) - - parsed = [(p,) + validate(p, args.force) for p in paths] - if args.dry_run: - for p, title, _body, labels, kind in parsed: - print("ok %s [type/%s] %s (%s)" % (p, kind, title, ", ".join(labels))) - return - - base = repo_base(args.repo) - login = require_login() - wanted = sorted({l for _p, _t, _b, labels, _k in parsed for l in labels}) - ids = load_label_ids(login, base, root, wanted) - - for path, title, body, labels, _kind in parsed: - payload = {"title": title, "body": body, "labels": [ids[l] for l in labels]} - slug = os.path.splitext(os.path.basename(path))[0] - iss = tea_api(login, "%s/issues" % base, "POST", payload, - payload_name="issue-%s" % slug, out_root=root) - if not isinstance(iss, dict) or "number" not in iss: - die("%s: create failed, unexpected response" % path) - n = iss["number"] - - got = [l.get("name", "") for l in iss.get("labels") or []] - missing = [l for l in labels if l not in got] - if missing: - tea_api(login, "%s/issues/%d/labels" % (base, n), "PUT", - {"labels": [ids[l] for l in labels]}, - payload_name="labels-%d" % n, out_root=root) - warn("#%d: labels re-applied via PUT (%s)" % (n, ", ".join(missing))) - - if args.keep: - write_file(issue_path(root, n), render_issue(iss)) - os.remove(path) - print("#%d %s %s -> %s" % (n, title, iss.get("html_url", ""), - issue_path(root, n))) - else: - os.remove(path) - print("#%d %s %s (draft removed)" % (n, title, iss.get("html_url", ""))) - - issue_index.build(root) - - -if __name__ == "__main__": - main()