feat: local issue cache and draft-then-push workflow

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-<slug>.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 <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-07 19:35:38 +05:00
parent 3549f37ebf
commit 335b0bbd54
12 changed files with 1190 additions and 249 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
{ {
"name": "tea", "name": "tea",
"source": "./", "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."
} }
] ]
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "tea", "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.", "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.1.0", "version": "1.2.0",
"author": { "author": {
"name": "naudachu" "name": "naudachu"
}, },
+12 -2
View File
@@ -8,6 +8,16 @@
## Repo layout ## Repo layout
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`) - `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/use``tea` CLI reference, loaded on demand (`/tea:use`); `references/` holds command docs and the canonical issue format; `scripts/` holds the issue scripts:
- `skills/issue` — create issues in the canonical format (`/tea:issue`) - `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-<slug>.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) - `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)
+17 -1
View File
@@ -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: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: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 | | `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
## Prerequisites ## Prerequisites
@@ -81,5 +82,20 @@ skills/
use/SKILL.md /tea:use skill use/SKILL.md /tea:use skill
use/references/tea/ tea CLI reference docs use/references/tea/ tea CLI reference docs
use/references/issue-format.md canonical issue format (types, templates) 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/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.
+54 -38
View File
@@ -1,57 +1,73 @@
--- ---
name: issue 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 Thin procedure on top of the canonical format defined in
[`../use/references/issue-format.md`](../use/references/issue-format.md). [`../use/references/issue-format.md`](../use/references/issue-format.md).
Read that file first — it is the single source of truth for types, labels, 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: templates, and language rules.
always `--login "$GITEA_LOGIN"`, never a literal name (see `/tea:use`).
## 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`. 1. **Read the format**: load `../use/references/issue-format.md`.
2. **Pick the type**`bug`, `task`, `refactor`, `test`, `feature` (a 2. **Pick the type**`bug`, `task`, `refactor`, `test`, `feature` (a
container for several issues with one business value), or `draft` (for 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 ideas not ready for work). If it is not obvious from the request, ask the
user (one question). user (one question).
3. **Ensure labels exist**: `tea labels list --login "$GITEA_LOGIN" -o json`. 3. **Write `tmp/issues/drafts/<slug>.md`**: a metadata block carrying
For each missing **exclusive** label (`type/*`, and `severity/*` when `labels:` only, then `# Title`, then the type's template.
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 ```markdown
set exclusivity. Non-exclusive `tech/*` and `comp/*` labels may be created ---
either way; apply them when the technology or component is evident. labels: [type/task, tech/sql, comp/appclick]
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, # Wire sqlc into the appclick repo layer
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. ## Summary
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": [<type-label-id>]} as JSON
tea api --login "$GITEA_LOGIN" -X POST -d @tmp/issue/<slug>.json \
repos/{owner}/{repo}/issues
``` ```
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). English imperative title with no type prefix; every section of the
The `labels` array holds every applied label: the `type/*` ID plus any template present and in order; headers English, prose Russian; `## Spec`
`severity/*`, `tech/*`, `comp/*` IDs. If labels fail to attach on create, filled with a repo path, a URL, or the literal `none` — ask the user if you
fall back to `PUT repos/{owner}/{repo}/issues/{n}/labels` with cannot determine which. Add `## Depends on` right after `## Spec` when the
`{"labels": [<id>]}`. issue depends on others (one `#N` per line); omit it otherwise.
6. **Report**: show the issue URL and the applied labels. 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 <url>`, 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/<n>.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 ## Editing an existing issue
When asked to bring an existing issue to the format: fetch it with the use Drafts only create. To bring an existing issue to the format: fetch it with
skill's script (`python3 ../use/scripts/fetch_issue.py <n>` relative to this `python3 ../use/scripts/issue_get.py <n>` (writes `tmp/issues/<n>.md`,
skill's base dir — writes `tmp/issue/<n>/data` + comments, prints a compact prints a compact line), restructure the body into the type's template without
index; no `--login`, it resolves the pin itself), restructure the body into losing information, then `PATCH repos/{owner}/{repo}/issues/{n}` via `tea api`
the type's template without losing information, then with the new title/body and ensure exactly one `type/*` label is set. Login is
`PATCH repos/{owner}/{repo}/issues/{n}` with the new title/body and ensure always the placeholder `--login "$GITEA_LOGIN"` (see `/tea:use`).
exactly one `type/*` label is set.
+104 -22
View File
@@ -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. per-project by the operator (see `/tea:auth`) and injected by the guard.
Config lives in `$XDG_CONFIG_HOME/tea`. 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 Never run `tea issues <n> -o json` or `tea api .../issues/<n>` to read an
`tea issues <n> -o json` or `tea api .../issues/<n>` directly — the full JSON issue — the full JSON payload (avatars, nested user objects, every comment
payload (avatars, nested user objects, every comment body) lands in your body) lands in your context whether you need it or not. Use the scripts in
context whether you need it or not. Instead run the bundled script; the only `<skill-base-dir>/scripts/`. They pull issues into a flat, greppable cache
input it needs is the issue key: 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 <key…>` or `issue_get.py --milestone M \| --label L \| -q TEXT` | yes | fetch issue(s) → `tmp/issues/<n>.md` (+ `<n>.comments.md`, `tree-<slug>.md`) |
| `issue_list.py [--state] [--label] [--milestone] [-q TEXT]` | yes | discovery: one line per issue to stdout, writes nothing |
| `issue_push.py <draft…>\|--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/<slug>.md issues not yet created in Gitea
```
Key forms for `<key>`: `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 ```bash
python3 <skill-base-dir>/scripts/fetch_issue.py 42 python3 <skill-base-dir>/scripts/issue_get.py --milestone 6 # id or title
python3 <skill-base-dir>/scripts/issue_get.py --milestone v0.2 --deps
python3 <skill-base-dir>/scripts/issue_get.py --label type/bug --label comp/hooks --state all
python3 <skill-base-dir>/scripts/issue_get.py -q sqlc --limit 20
``` ```
Key forms: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo defaults Filters AND together; `--state` defaults to `open`; `--limit` defaults to 100.
to the current directory's git remote (add `--repo owner/repo` outside one). 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:
``` - **Gitea silently ignores an unresolvable milestone filter** and returns the
tmp/issue/42/data issue: metadata header + body whole backlog. The script resolves the milestone first (exits listing the
tmp/issue/42/comments/ one file per comment: NNN-<comment-id>.md 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 <skill-base-dir>/scripts/issue_get.py 40 --deps # feature + children
``` ```
Then Read just the files the task needs — often the `data` file alone, or a Read `tree-40.md` once for the shape (a filtered fetch writes one forest,
single comment picked from the index (author + date per line). Each comment `tree-<slug>.md`), then grep the files as one document:
file carries its `comment-id`, ready for a `PATCH` via `tea api`.
Notes: ```bash
- No `--login` on the script call: the script resolves the operator's pinned grep -ln 'depends:.*#42' tmp/issues/*.md # who depends on #42 (upwards)
login itself from `.claude/settings.local.json` — same source as the grep -l 'labels:.*type/bug' tmp/issues/*.md # all cached bugs
tea-guard hook. No pin → it exits with a pointer to `/tea:auth`. grep -A3 '## Acceptance criteria' tmp/issues/4*.md
- Every run refetches fresh and wipes the issue's `comments/` dir, so stale grep -c '^- \[ \]' tmp/issues/42.md # open checkboxes
files never survive. ```
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/<slug>.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/<n>.md` instead;
`--dry-run` validates without touching the network. Guided procedure:
`/tea:issue`.
## Index ## 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 fences / backticks / pipes / tables), bypass entity commands. Save the full
request payload to `$PWD/tmp/` first, then POST via `tea api`. 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 ### Procedure
1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is 1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is
+406
View File
@@ -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/<n>.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 <out_root>/.payload/<payload_name>.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 <root>/.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}
-183
View File
@@ -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/<n>/data issue itself (metadata header + body)
tmp/issue/<n>/comments/ one file per comment: NNN-<comment-id>.md
Usage:
fetch_issue.py <key> [--repo owner/repo] [--out DIR]
<key> 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/<n>/")
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()
+261
View File
@@ -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/<n>.md metadata block + `# Title` + body
tmp/issues/<n>.comments.md comments (only with --comments)
tmp/issues/tree-<slug>.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()
+94
View File
@@ -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 <n>`; 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()
+51
View File
@@ -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 "<n>"))
if __name__ == "__main__":
main()
+188
View File
@@ -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/<n>.md) instead of deleting it.
Usage:
issue_push.py <draft.md> [<draft.md> …] [--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/<n>.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()