From 335b0bbd5444a367df794d9b5c14b9d6f87f523f Mon Sep 17 00:00:00 2001 From: naudachu Date: Fri, 7 Aug 2026 19:35:38 +0500 Subject: [PATCH] feat: local issue cache and draft-then-push workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fetch_issue.py with four scripts around a flat, greppable cache in tmp/issues/. Planning stays offline and issues reach Gitea in one push: - issue_get.py: fetch by key or by filter (--milestone/--label/-q). The list endpoint carries issue bodies, so a whole milestone costs one request per 50 issues. Gitea silently ignores an unresolvable milestones= filter and returns the entire backlog, so the milestone is resolved up front and every returned issue is re-checked locally. --deps walks the dependency graph downwards via the structured sections plus native dependencies and writes tree-.md. - issue_push.py: validate a local draft against the canonical format, create missing labels with the right colors and exclusivity, POST, delete the draft. - issue_list.py: discovery to stdout, writes nothing. - issue_index.py: rebuild INDEX.md from what is on disk. Files use one metadata field per line with inline lists so plain grep works without a parser. This is a cache and a drafting area, not a mirror: no drift tracking, no sync back. Projects are not fetchable — the projects API is 404 on Gitea 1.26; documented alongside the milestone caveat. Co-Authored-By: Claude Opus 5 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 4 +- AGENTS.md | 14 +- README.md | 18 +- skills/issue/SKILL.md | 92 ++++--- skills/use/SKILL.md | 126 ++++++++-- skills/use/scripts/_tea.py | 406 ++++++++++++++++++++++++++++++ skills/use/scripts/fetch_issue.py | 183 -------------- 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 ++++++++++++++ 12 files changed, 1190 insertions(+), 249 deletions(-) create mode 100755 skills/use/scripts/_tea.py delete mode 100644 skills/use/scripts/fetch_issue.py create mode 100755 skills/use/scripts/issue_get.py create mode 100755 skills/use/scripts/issue_index.py create mode 100755 skills/use/scripts/issue_list.py create mode 100755 skills/use/scripts/issue_push.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 52bf7ed..7edc3f0 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, and a PreToolUse hook that blocks any tea command that would touch Gitea without --login." + "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." } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9cbe67f..3f6cb2b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tea", - "description": "Gitea CLI (tea) reference plus a mandatory-login guard. Ships /tea:auth (pin a login), /tea:use (command reference), /tea:issue (create issues in a canonical format), and a PreToolUse hook that blocks any tea command that would touch Gitea without --login.", - "version": "1.1.0", + "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", "author": { "name": "naudachu" }, diff --git a/AGENTS.md b/AGENTS.md index 17a01a1..47f4ff5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,16 @@ ## Repo layout - `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`) -- `skills/use` — `tea` CLI reference, loaded on demand (`/tea:use`); `scripts/` holds helper scripts (e.g. `fetch_issue.py`), `references/` holds command docs and the canonical issue format -- `skills/issue` — create issues in the canonical format (`/tea:issue`) +- `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`) + +## Local issue cache + +`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) diff --git a/README.md b/README.md index e59f1a6..e5e55a0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforce |---|---| | `/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 | Creates issues in a canonical format (typed labels, fixed sections) | +| `/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-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation | ## Prerequisites @@ -81,5 +82,20 @@ skills/ 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 ``` + +## Local issue cache + +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. diff --git a/skills/issue/SKILL.md b/skills/issue/SKILL.md index 55f70cd..3b7213e 100644 --- a/skills/issue/SKILL.md +++ b/skills/issue/SKILL.md @@ -1,57 +1,73 @@ --- 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. Ensures exclusive type/* labels exist, composes the body from the type's template, and posts via tea api. Format lives in the use skill's references. +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. --- -# /tea:issue — create an issue in the canonical format +# /tea:issue — draft locally, push when agreed 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. Login rules are the same as everywhere: -always `--login "$GITEA_LOGIN"`, never a literal name (see `/tea:use`). +templates, and language rules. -## Steps +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). + +## Phase 1 — draft (no network) 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. **Ensure labels exist**: `tea labels list --login "$GITEA_LOGIN" -o json`. - For each missing **exclusive** label (`type/*`, and `severity/*` when - used), create it via `tea api` with `"exclusive": true` exactly as shown - in the format doc. Do NOT use `tea labels create` for these — it cannot - set exclusivity. Non-exclusive `tech/*` and `comp/*` labels may be created - either way; apply them when the technology or component is evident. -4. **Compose title and body** per the format: English imperative title without - a type prefix; the type's template with all sections present, in order, - headers in English, prose in Russian; `## Spec` filled with a repo path, - a URL, or the literal `none` — ask the user if you cannot determine which. - If the issue depends on others, add a `## Depends on` section right after - `## Spec` (one `#N` per line); omit it otherwise. -5. **Post via tmp/ + tea api** (the body is always multi-line, so entity - commands are off the table — see "Rich payloads" in `/tea:use`): - ```bash - mkdir -p tmp/issue - # write {"title": "...", "body": "...", "labels": []} as JSON - tea api --login "$GITEA_LOGIN" -X POST -d @tmp/issue/.json \ - repos/{owner}/{repo}/issues +3. **Write `tmp/issues/drafts/.md`**: a metadata block carrying + `labels:` only, then `# Title`, then the type's template. + + ```markdown + --- + labels: [type/task, tech/sql, comp/appclick] + --- + # Wire sqlc into the appclick repo layer + + ## Summary + ... ``` - The create endpoint takes label **IDs** (integers), not names — take them - from the `tea labels list` output of step 3 (or from the create response). - The `labels` array holds every applied label: the `type/*` ID plus any - `severity/*`, `tech/*`, `comp/*` IDs. If labels fail to attach on create, - fall back to `PUT repos/{owner}/{repo}/issues/{n}/labels` with - `{"labels": []}`. -6. **Report**: show the issue URL and the applied labels. + + 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 + ``` + +## Phase 2 — push (once the plan is agreed) + +```bash +python3 ../use/scripts/issue_push.py --all +``` + +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. + +Flags: `--keep` writes `tmp/issues/.md` instead of deleting, `--dry-run` +validates only, `--force` posts despite format violations (say why). + +Report the issue URLs and the applied labels to the user. ## Editing an existing issue -When asked to bring an existing issue to the format: fetch it with the use -skill's script (`python3 ../use/scripts/fetch_issue.py ` relative to this -skill's base dir — writes `tmp/issue//data` + comments, prints a compact -index; no `--login`, it resolves the pin itself), restructure the body into -the type's template without losing information, then -`PATCH repos/{owner}/{repo}/issues/{n}` with the new title/body and ensure -exactly one `type/*` label is set. +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`). diff --git a/skills/use/SKILL.md b/skills/use/SKILL.md index 889f369..3d37738 100644 --- a/skills/use/SKILL.md +++ b/skills/use/SKILL.md @@ -46,38 +46,116 @@ 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`. -## Reading an issue: use the fetch script, not raw tea calls +## Issues: work on local files, not on live `tea` calls -To read an existing issue (its body, its discussion), do NOT run -`tea issues -o json` or `tea api .../issues/` directly — the full JSON -payload (avatars, nested user objects, every comment body) lands in your -context whether you need it or not. Instead run the bundled script; the only -input it needs is the issue key: +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/fetch_issue.py 42 +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 ``` -Key forms: `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). +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. -It writes trimmed markdown files locally and prints only a compact index: +Two traps this handles for you: -``` -tmp/issue/42/data issue: metadata header + body -tmp/issue/42/comments/ one file per comment: NNN-.md +- **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 ``` -Then Read just the files the task needs — often the `data` file alone, or a -single comment picked from the index (author + date per line). Each comment -file carries its `comment-id`, ready for a `PATCH` via `tea api`. +Read `tree-40.md` once for the shape (a filtered fetch writes one forest, +`tree-.md`), then grep the files as one document: -Notes: -- No `--login` on the script call: the script resolves the operator's pinned - login itself from `.claude/settings.local.json` — same source as the - tea-guard hook. No pin → it exits with a pointer to `/tea:auth`. -- Every run refetches fresh and wipes the issue's `comments/` dir, so stale - files never survive. +```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 @@ -104,6 +182,10 @@ 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. + ### Procedure 1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is diff --git a/skills/use/scripts/_tea.py b/skills/use/scripts/_tea.py new file mode 100755 index 0000000..f9a6e43 --- /dev/null +++ b/skills/use/scripts/_tea.py @@ -0,0 +1,406 @@ +#!/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/fetch_issue.py b/skills/use/scripts/fetch_issue.py deleted file mode 100644 index 78d7d7b..0000000 --- a/skills/use/scripts/fetch_issue.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -""" -fetch_issue.py — pull one Gitea issue (+ all comments) to local files. - -Token-saving fetcher for Claude sessions: instead of dumping raw API JSON -into the conversation, it writes trimmed markdown files under tmp/issue/ -and prints only a compact index. Read the files you actually need. - - tmp/issue//data issue itself (metadata header + body) - tmp/issue//comments/ one file per comment: NNN-.md - -Usage: - fetch_issue.py [--repo owner/repo] [--out DIR] - - 42 | #42 | owner/repo#42 | https://host/owner/repo/issues/42 - -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 script never accepts a login argument: the operator's pin is the only -identity it will use. No pin -> exit with a pointer to /tea:auth. -""" -import argparse -import json -import os -import re -import shutil -import subprocess -import sys - - -def die(msg, code=1): - sys.stderr.write("fetch_issue: " + msg + "\n") - sys.exit(code) - - -def find_pin(start_dir): - """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 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 tea_api(login, endpoint): - """GET via `tea api`, return parsed JSON.""" - cmd = ["tea", "api", "--login", login, endpoint] - r = subprocess.run(cmd, capture_output=True, text=True) - if r.returncode != 0: - die("`tea api %s` failed:\n%s" % (endpoint, (r.stderr or r.stdout).strip())) - try: - return json.loads(r.stdout) - except json.JSONDecodeError: - die("`tea api %s` returned non-JSON:\n%s" % (endpoint, r.stdout[:500])) - - -def day(iso): - return (iso or "")[:10] - - -def issue_markdown(iss): - labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "none" - assignees = ", ".join(a.get("login", "") for a in iss.get("assignees") or []) or "none" - milestone = (iss.get("milestone") or {}).get("title") or "none" - lines = [ - "#%d %s" % (iss["number"], iss.get("title", "")), - "state: %s" % iss.get("state", ""), - "labels: %s" % labels, - "author: %s" % (iss.get("user") or {}).get("login", ""), - "assignees: %s" % assignees, - "milestone: %s" % milestone, - "created: %s" % iss.get("created_at", ""), - "updated: %s" % iss.get("updated_at", ""), - "url: %s" % iss.get("html_url", ""), - "comments: %d" % iss.get("comments", 0), - "", - "---", - "", - iss.get("body") or "(no body)", - "", - ] - return "\n".join(lines) - - -def comment_markdown(c): - lines = [ - "comment-id: %d" % c["id"], - "author: %s" % (c.get("user") or {}).get("login", ""), - "created: %s" % c.get("created_at", ""), - "updated: %s" % c.get("updated_at", ""), - "", - "---", - "", - c.get("body") or "(empty)", - "", - ] - return "\n".join(lines) - - -def main(): - ap = argparse.ArgumentParser(description="Fetch a Gitea issue + comments to tmp/issue//") - ap.add_argument("key", help="issue key: 42, #42, owner/repo#42, or issue URL") - ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)") - ap.add_argument("--out", default=os.path.join("tmp", "issue"), - help="output root (default: tmp/issue)") - args = ap.parse_args() - - number, key_repo = parse_key(args.key) - repo = args.repo or key_repo # None -> let tea fill {owner}/{repo} from CWD - base = "repos/%s" % repo if repo else "repos/{owner}/{repo}" - - login = find_pin(os.getcwd()) - if not login: - die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.") - - iss = tea_api(login, "%s/issues/%d" % (base, number)) - - comments = [] - page = 1 - while page <= 40: - batch = tea_api(login, "%s/issues/%d/comments?page=%d&limit=50" % (base, number, page)) - if not isinstance(batch, list) or not batch: - break - comments.extend(batch) - if len(batch) < 50: - break - page += 1 - - root = os.path.join(args.out, str(number)) - cdir = os.path.join(root, "comments") - shutil.rmtree(cdir, ignore_errors=True) # drop stale comments from earlier fetches - os.makedirs(cdir, exist_ok=True) - - data_path = os.path.join(root, "data") - with open(data_path, "w") as f: - f.write(issue_markdown(iss)) - - index = [] - for i, c in enumerate(comments, 1): - name = "%03d-%d.md" % (i, c["id"]) - with open(os.path.join(cdir, name), "w") as f: - f.write(comment_markdown(c)) - index.append((name, (c.get("user") or {}).get("login", ""), day(c.get("created_at")))) - - # Compact index — the only thing that lands in the model's context. - labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "no labels" - print("#%d %s [%s] %s — %s, updated %s" % ( - iss["number"], iss.get("title", ""), iss.get("state", ""), labels, - (iss.get("user") or {}).get("login", ""), day(iss.get("updated_at")))) - print(data_path) - print("comments: %d" % len(comments)) - for name, author, created in index: - print("%s %s %s" % (os.path.join(cdir, name), author, created)) - - -if __name__ == "__main__": - main() diff --git a/skills/use/scripts/issue_get.py b/skills/use/scripts/issue_get.py new file mode 100755 index 0000000..c4511b3 --- /dev/null +++ b/skills/use/scripts/issue_get.py @@ -0,0 +1,261 @@ +#!/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 new file mode 100755 index 0000000..44ace44 --- /dev/null +++ b/skills/use/scripts/issue_index.py @@ -0,0 +1,94 @@ +#!/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 new file mode 100755 index 0000000..a755bd7 --- /dev/null +++ b/skills/use/scripts/issue_list.py @@ -0,0 +1,51 @@ +#!/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 new file mode 100755 index 0000000..7a5ce0a --- /dev/null +++ b/skills/use/scripts/issue_push.py @@ -0,0 +1,188 @@ +#!/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()