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:
@@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Executable
+406
@@ -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}
|
||||||
@@ -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()
|
|
||||||
Executable
+261
@@ -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()
|
||||||
Executable
+94
@@ -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()
|
||||||
Executable
+51
@@ -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()
|
||||||
Executable
+188
@@ -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()
|
||||||
Reference in New Issue
Block a user