refactor: split issue domain from Gitea transport

An issue was a Gitea row that happened to be cached locally: its identity
was the tracker's number (42.md), its dependencies were tracker numbers
(depends: [#12]), and a local issue existed only as a draft that push
deleted on success. Nothing could be planned or tracked without a tracker.

Split into layers, with knowledge flowing one way:

  skills/issue  DOMAIN  what an issue is: format, validation, dep graph
        ^               offline; stdlib imports only, no subprocess
        | imports
  skills/sync   BRIDGE  map.py    md <-> Gitea JSON, pure, no I/O
                        _gitea.py login pin, api, pagination, filters
  skills/use    REFERENCE  tea CLI docs for non-issue entities

skills/issue never imports skills/sync. Delete the sync layer and the
domain keeps working.

Identity is now a slug derived from the title (wire-sqlc-appclick.md) and
is stable across retitles and pushes. Tracker numbers live in a `gitea:`
field, never in a file name and never in `depends:`; the pair is indexed
in .remote.json, which is a cache over the files, not a second source of
truth.

Behavior changes:

- Pushing is additive. The file is never deleted; it gains gitea:/url:/
  synced: and origin: flips from local to gitea. `origin: local` is a
  durable state, not a pending one.
- Pushes go in topological order so dependencies get numbers first.
- The dependency graph is computed offline from `depends:` metadata; body
  prose is passed through unchanged in both directions rather than being
  rewritten between slugs and #N.
- `origin` is domain-owned (whether work exists elsewhere is a fact about
  the work); the handle and how to reach it stay with sync.

Script moves:

  issue_get.py   -> sync/pull.py
  issue_push.py  -> sync/push.py
  issue_list.py  -> sync/remote.py
  issue_index.py -> issue/issue_index.py
  _tea.py        -> split into issue/issue.py, sync/map.py, sync/_gitea.py

New: issue/issue_new.py, issue/issue_check.py, issue/issue_tree.py, and
sync/comment.py — comment posting was the last issue operation still
hand-rolled through raw `tea api`.

references/issue-format.md moves to skills/issue/references/format.md;
label hex colors move out of it into map.py, since a color is how a
tracker paints a chip, not what an issue is.

Verified: offline path end to end (new, check, tree, index, push
--dry-run) and read-only against Gitea (remote listing, pull with
mapping, comment guard). Write paths of push.py and comment.py are not
exercised here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-09 23:37:32 +05:00
parent 335b0bbd54
commit 091dceec1d
24 changed files with 2504 additions and 1284 deletions
+35 -136
View File
@@ -1,6 +1,6 @@
---
name: use
description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, issues, pulls, releases, actions, or other Gitea entities, to look up the right `tea` command and flags. Always write the login as the literal placeholder --login "$GITEA_LOGIN" — the tea-guard hook substitutes the operator-pinned login; set it with /tea:auth.
description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, pulls, releases, milestones, labels, actions, webhooks, or other Gitea entities, to look up the right `tea` command and flags. Always write the login as the literal placeholder --login "$GITEA_LOGIN" — the tea-guard hook substitutes the operator-pinned login; set it with /tea:auth. Issues are NOT handled here: use /tea:issue to work on them and /tea:sync to move them to and from Gitea.
---
# /tea:use — tea CLI reference
@@ -9,6 +9,19 @@ Reference material for the `tea` CLI (Gitea's official command-line client).
Use these docs to look up commands, flags, filters, and output fields before
running `tea` via Bash.
## Issues are somewhere else
Do **not** reach for `tea issues` or `tea api .../issues/...` to read or create
an issue. Two skills own that, and they keep the payload out of your context:
| Skill | Scope |
|---|---|
| `/tea:issue` | issues as units of work — create, read, grep, validate, dependency graph. Offline. |
| `/tea:sync` | moving issues between the local store and Gitea — pull, push, comment. |
This skill covers everything else Gitea has: pulls, releases, milestones,
labels, repos, branches, actions, webhooks, notifications, times.
## Login: always write the placeholder, never a name (enforced)
Every `tea` invocation that touches Gitea MUST carry the login as the **literal
@@ -33,12 +46,11 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
## How to use
1. Identify the entity in the request: issues, pulls, labels, milestones,
releases, times, repos, branches, actions, webhooks, comments,
notifications, etc.
1. Identify the entity in the request: pulls, labels, milestones, releases,
times, repos, branches, actions, webhooks, notifications, etc.
2. Find the matching command in the index below.
3. Run it via Bash with the placeholder login, e.g.
`tea issues list --login "$GITEA_LOGIN" --repo owner/repo --state open`.
`tea pulls list --login "$GITEA_LOGIN" --repo owner/repo --state open`.
(The hook rewrites `"$GITEA_LOGIN"` to the operator-pinned login.)
`tea` auto-detects owner/repo from `$PWD` inside a git repo; otherwise pass
@@ -46,117 +58,6 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
per-project by the operator (see `/tea:auth`) and injected by the guard.
Config lives in `$XDG_CONFIG_HOME/tea`.
## Issues: work on local files, not on live `tea` calls
Never run `tea issues <n> -o json` or `tea api .../issues/<n>` to read an
issue — the full JSON payload (avatars, nested user objects, every comment
body) lands in your context whether you need it or not. Use the scripts in
`<skill-base-dir>/scripts/`. They pull issues into a flat, greppable cache
under `$PWD/tmp/issues/` and print only a compact index.
This is a **cache, not a mirror**: nothing tracks drift, nothing syncs back.
Refetch when you need current data; the `fetched:` field tells you the age.
| Script | Network | What it does |
|---|---|---|
| `issue_get.py <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
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
```
Filters AND together; `--state` defaults to `open`; `--limit` defaults to 100.
Keys and filters are mutually exclusive. `--comments` stays single-issue —
loop over the numbers when a whole thread set is needed.
Two traps this handles for you:
- **Gitea silently ignores an unresolvable milestone filter** and returns the
whole backlog. The script resolves the milestone first (exits listing the
real ones if it does not exist) and re-checks every returned issue locally.
Never trust a raw `tea api ...issues?milestones=X` call for this.
- **Projects are not fetchable.** The projects API is not exposed (404 on
Gitea 1.26 for `repos/…/projects`, `orgs/…/projects`, `projects/{id}`).
Use milestones or labels; project columns live in the web UI only.
After a filtered fetch, `INDEX.md` carries a `milestone` column, and the cache
is greppable by it: `grep -l 'milestone: v0.2' tmp/issues/*.md`.
### Working a feature as one document
`--deps` walks the dependency graph **downwards** — the structured
`## Depends on` and `## Issues` sections plus Gitea's native dependencies.
Prose `#N` mentions are ignored on purpose, or the walk would drag in half the
backlog. Comments are not fetched during a walk (loop over the numbers if you
need them).
```bash
python3 <skill-base-dir>/scripts/issue_get.py 40 --deps # feature + children
```
Read `tree-40.md` once for the shape (a filtered fetch writes one forest,
`tree-<slug>.md`), then grep the files as one document:
```bash
grep -ln 'depends:.*#42' tmp/issues/*.md # who depends on #42 (upwards)
grep -l 'labels:.*type/bug' tmp/issues/*.md # all cached bugs
grep -A3 '## Acceptance criteria' tmp/issues/4*.md
grep -c '^- \[ \]' tmp/issues/42.md # open checkboxes
```
Metadata is written one field per line with inline lists (`labels: [a, b]`)
precisely so plain grep works without a parser.
### Creating issues: draft locally, push once
During planning write drafts to `tmp/issues/drafts/<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
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
@@ -164,15 +65,14 @@ issue lives in Gitea now. `--keep` writes `tmp/issues/<n>.md` instead;
- [HELPERS](references/tea/helpers.md) — open, notifications, clone, api
- [MISC](references/tea/misc.md) — whoami, admin
- [SETUP](references/tea/setup.md) — logins, logout, ssh-keys
- [ISSUE FORMAT](references/issue-format.md) — canonical issue format: label
namespaces (`type/*`, `severity/*` exclusive; `tech/*`, `comp/*` free),
types `bug|task|refactor|test|feature|draft`, templates, dependencies,
title and language rules. MANDATORY whenever creating or editing an issue;
the `/tea:issue` skill is the guided procedure for it.
The canonical issue format moved to
[`../issue/references/format.md`](../issue/references/format.md) — it describes
local files, not `tea` commands.
## Rich payloads — write to `$PWD/tmp/` first, then `tea api`
Entity subcommands (`tea comment`, `tea issues create`, `tea pulls create`, …)
Entity subcommands (`tea comment`, `tea pulls create`, `tea releases create`, …)
are built for humans at a TTY. With a large or formatted body they can hang
silently — an empty-looking positional arg triggers `$EDITOR` fallback, or a
scope/confirm prompt waits on a TTY that doesn't exist. The harness eventually
@@ -182,30 +82,29 @@ kills the process (e.g. exit 144 = 128 + SIGURG on macOS).
fences / backticks / pipes / tables), bypass entity commands. Save the full
request payload to `$PWD/tmp/` first, then POST via `tea api`.
Issue creation is already wrapped: use `issue_push.py` (above) instead of
hand-rolling the JSON. The procedure below covers everything else — comments,
pulls, releases, and PATCHes to existing issues.
Issues and issue comments are already wrapped use `/tea:sync` rather than
hand-rolling their JSON. The procedure below covers everything else.
### Procedure
1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is
`comment`, `issue`, `pull`, `release`, etc.
`pull`, `release`, etc.
2. Write the **complete request body as JSON** to `$PWD/tmp/{kind}/<slug>.json`.
One file = one request. Use a quoted heredoc to avoid shell expansion:
```bash
mkdir -p tmp/comment
cat > tmp/comment/issue-60.json <<'EOF'
{"body": "## Heading\n\nMulti-line markdown with `code`, | tables |, and ```fences```."}
mkdir -p tmp/release
cat > tmp/release/v0-2-0.json <<'EOF'
{"tag_name": "v0.2.0", "name": "v0.2.0", "body": "## Changes\n\nMulti-line markdown with `code`."}
EOF
```
Newlines inside the body must be encoded as `\n` in the JSON string. If
composing programmatically, pipe through
`jq -Rs '{body: .}' < body.md > tmp/comment/issue-60.json`.
`jq -Rs '{body: .}' < body.md > tmp/release/v0-2-0.json`.
3. POST with `tea api`, passing the file with `-d @<path>`:
```bash
tea api --login "$GITEA_LOGIN" \
-X POST -d @tmp/comment/issue-60.json \
repos/{owner}/{repo}/issues/60/comments
-X POST -d @tmp/release/v0-2-0.json \
repos/{owner}/{repo}/releases
```
4. Keep the file. `tmp/` should be gitignored; the saved payload is useful for
retries, edits (`PATCH`), and debugging failed posts.
@@ -214,12 +113,12 @@ pulls, releases, and PATCHes to existing issues.
| Action | Method + endpoint |
|---|---|
| Comment on issue/PR | `POST repos/{owner}/{repo}/issues/{n}/comments` |
| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` |
| Create issue | `POST repos/{owner}/{repo}/issues` |
| Edit issue/PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` |
| Create PR | `POST repos/{owner}/{repo}/pulls` |
| Edit PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` |
| Comment on a PR | `POST repos/{owner}/{repo}/issues/{n}/comments` |
| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` |
| Create release | `POST repos/{owner}/{repo}/releases` |
| Create milestone | `POST repos/{owner}/{repo}/milestones` |
Short single-line bodies (e.g. `tea comment 42 "lgtm" --login "$GITEA_LOGIN"`)
are still fine via entity commands. Always the placeholder, never a login name.
-245
View File
@@ -1,245 +0,0 @@
# Issue format
Canonical format for every issue created or edited via `tea`. Designed to be
unambiguous for both humans and LLMs: fixed English section headers in a fixed
order, verifiable acceptance criteria, one issue = one deliverable. Source
spec: the project wiki ([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
## Language rules
- **Issue title**: English, imperative mood, no type prefix — the type lives in
the label, not the title. Good: `Fix tea-guard crash on empty settings file`.
Bad: `fix: crash`, `[bug] crash`, `Крашится гвард`.
- **Section headers**: the exact English literals below, as `##` headings, in
the given order. Do not translate, rename, or reorder them.
- **Body prose** (text inside sections): Russian.
## Label namespaces
Four namespaces classify an issue. Two are exclusive (Gitea enforces at most
one label from the scope), two are free-form:
| Namespace | Exclusive | Purpose |
|---|---|---|
| `type/*` | yes | What kind of work; primarily its business value. Mandatory, exactly one. |
| `severity/*` | yes | Business impact. At most one; apply when the impact is known. |
| `tech/*` | no | Technology the issue is bound to. Any number. |
| `comp/*` | no | System component of this repo. Any number; no preset — project-specific. |
### `type/*` — mandatory, exactly one
| Label | Color | Meaning |
|---|---|---|
| `type/bug` | `#ee0701` | Something behaves incorrectly in existing code |
| `type/task` | `#0e8a16` | Implementation of new functionality |
| `type/refactor` | `#1d76db` | Internal restructuring: file moves, architecture; behavior must not change |
| `type/test` | `#fbca04` | Writing or fixing tests |
| `type/feature` | `#5319e7` | Container: several issues delivering one unit of business value |
| `type/draft` | `#cccccc` | Idea captured for later; not ready for work |
### `severity/*` — at most one
| Label | Color |
|---|---|
| `severity/low` | `#c2e0c6` |
| `severity/medium` | `#fbca04` |
| `severity/high` | `#eb6420` |
| `severity/showstopper` | `#ee0701` |
| `severity/critical` | `#b60205` |
### `tech/*` — any number
Technology-bound labels, e.g. `tech/sql` (pgx, sqlc, sql-migrate — persistent
storage), `tech/obs` (grafana, loki, prometheus, alloy — observability),
`tech/postgres`.
### `comp/*` — any number
Components of this repo's system, e.g. `comp/appclick`. No preset list —
derive from the project.
### Creating exclusive labels
Gitea enforces exclusivity only if the label was created with
`exclusive: true`. The `tea labels create` command (as of tea 0.14.2) cannot
set that field, so missing `type/*` and `severity/*` labels MUST be created
via `tea api`:
```bash
tea api --login "$GITEA_LOGIN" -X POST \
-d '{"name":"type/bug","color":"#ee0701","exclusive":true,"description":"Something behaves incorrectly in existing code"}' \
repos/{owner}/{repo}/labels
```
`tech/*` and `comp/*` are non-exclusive; either `tea labels create` or
`tea api` works for them.
## Dependencies
An issue may explicitly depend on others. Declare that in an optional
`## Depends on` section placed right after `## Spec`, one `#N` reference per
line:
```markdown
## Depends on
- #12 — нужна схема БД из этого issue
- #15
```
Omit the section when there are no dependencies — never write an empty one.
## Shared rules
- `## Summary` is always the first section; `## Acceptance criteria` is always
present (exception: `type/draft`). These two are the anchors every reader
(human or LLM) relies on.
- `## Spec` is **mandatory in every type**. Its value is a repo path
(`docs/specs/auth.md`), a URL, or the literal `none` when no spec exists.
Never omit the section and never invent a link — `none` is an explicit,
valid answer.
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
checkable condition, not an aspiration.
- Code references use the `path/file.ext:line` form; related issues as `#N`.
- Screenshots are allowed but their content must be duplicated as text — an
LLM posting through `tea api` cannot read images.
- If acceptance criteria grow past ~5 unrelated items, split the issue (or
promote it to a `type/feature` container with child issues).
## Template: `type/bug`
```markdown
## Summary
Что сломано и где проявляется, одно-два предложения.
## Spec
`docs/specs/auth.md`, URL — или `none`.
## Steps to reproduce
1.
2.
## Expected
Что должно было произойти.
## Actual
Что происходит на самом деле: вывод команды, лог.
## Environment
Только релевантное: версии, ОС, конфигурация.
## Acceptance criteria
- [ ] баг не воспроизводится по шагам выше
- [ ] добавлена проверка на регрессию (если применимо)
```
## Template: `type/task`
```markdown
## Summary
Что нужно сделать, одно-два предложения.
## Spec
Ссылка или `none`.
## Motivation
Какую проблему пользователя/системы это решает.
## Acceptance criteria
- [ ] проверяемое условие
- [ ]
## Constraints
Что НЕ входит в объём; технические рамки. (опционально)
```
## Template: `type/refactor`
```markdown
## Summary
Что перестраиваем и в каких файлах (`path/file:line`).
## Spec
Ссылка или `none`.
## Motivation
Чем плохо текущее состояние: дублирование, связность, читаемость.
## Invariants
Что НЕ должно измениться: поведение, публичные API, форматы данных.
## Acceptance criteria
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
```
## Template: `type/test`
```markdown
## Summary
Что покрываем тестами и где (`path/file:line`).
## Spec
Ссылка или `none`.
## Motivation
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
## Test cases
- сценарий → ожидаемый результат
-
## Acceptance criteria
- [ ] перечисленные кейсы покрыты и зелёные
- [ ] тесты проходят в CI
```
## Template: `type/feature`
A container: one unit of business value delivered by several child issues.
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and link
back via `## Depends on` or the `## Issues` list here. Keep implementation
detail in the children; the feature body stays at business level.
```markdown
## Summary
Бизнес-ценность одним-двумя предложениями.
## Spec
Ссылка или `none`.
## Motivation
Какую проблему пользователя/системы это решает.
## Issues
- [ ] #N — краткое описание части
- [ ]
## Acceptance criteria
- [ ] все дочерние issues закрыты
- [ ] проверяемое условие уровня фичи (например, e2e-сценарий работает)
```
## Template: `type/draft`
A parking spot for ideas that are not fleshed out yet. Minimal structure, no
acceptance criteria required. Before implementation starts, a draft MUST be
promoted: relabeled to a concrete type and rewritten into that type's
template.
```markdown
## Summary
Идея одним-двумя предложениями.
## Spec
Ссылка или `none` (для драфтов обычно `none`).
## Notes
Свободные заметки: что известно, открытые вопросы, варианты.
```
## Containers beyond `type/feature`
- **Milestone** — a set of issues with an optional time bound. Manage via
`tea milestones` / `tea milestones issues`.
- **Project** — a set of issues describing one project, tracked by status
columns. Standard statuses: Backlog, ToDo, InProgress, Ready, Done. The
Gitea projects API is not exposed via `tea` subcommands — use the web UI.
-406
View File
@@ -1,406 +0,0 @@
#!/usr/bin/env python3
"""
_tea.py — shared helpers for the issue scripts (get / push / list / index).
Not a command. Holds the three things every script needs: the operator's
pinned login, a `tea api` wrapper, and the grep-friendly on-disk issue format.
On-disk format (tmp/issues/<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}
-261
View File
@@ -1,261 +0,0 @@
#!/usr/bin/env python3
"""
issue_get.py — pull Gitea issues into the local grep cache under tmp/issues/.
Token-saving fetcher: instead of dumping raw API JSON into the conversation it
writes flat, grep-friendly markdown and prints a compact index. Read only the
files the task needs.
tmp/issues/<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
@@ -1,94 +0,0 @@
#!/usr/bin/env python3
"""
issue_index.py — rebuild tmp/issues/INDEX.md from what is on disk. No network.
The index is a map of the cache, nothing else: issues that were never fetched
do not appear. issue_get.py and issue_push.py call it automatically; run it by
hand only after deleting files.
Usage:
issue_index.py [--out tmp/issues]
"""
import argparse
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _tea import ISSUE_ROOT, parse_meta, read_file, write_file # noqa: E402
def cell(v):
if isinstance(v, list):
return ", ".join(v) or ""
v = (v or "").strip()
return v.replace("|", "\\|") or ""
def build(root):
rows = []
for name in sorted(os.listdir(root)) if os.path.isdir(root) else []:
m = re.match(r'^(\d+)\.md$', name)
if not m:
continue
n = int(m.group(1))
meta, title, _ = parse_meta(read_file(os.path.join(root, name)))
labels = meta.get("labels") or []
if isinstance(labels, str):
labels = [labels]
types = [l for l in labels if l.startswith("type/")]
rest = [l for l in labels if not l.startswith("type/")]
has_comments = os.path.isfile(os.path.join(root, "%d.comments.md" % n))
rows.append({
"n": n,
"state": cell(meta.get("state")),
"type": cell(types[0].split("/", 1)[1] if types else ""),
"labels": cell(rest),
"title": cell(title),
"milestone": cell(meta.get("milestone")),
"depends": cell(meta.get("depends")),
"comments": ("[%s](%d.comments.md)" % (cell(meta.get("comments")), n)
if has_comments else ""),
"fetched": cell(meta.get("fetched"))[:10],
})
rows.sort(key=lambda r: r["n"])
trees = sorted(f for f in (os.listdir(root) if os.path.isdir(root) else [])
if re.match(r'^tree-\d+\.md$', f))
out = ["# Issue cache", "",
"Local cache of fetched issues — not a mirror. Refresh with "
"`issue_get.py <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
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
"""
issue_list.py — discovery: which issue numbers exist, one line each.
Prints to stdout and writes nothing: INDEX.md is a map of the local cache, and
this command deliberately does not pollute it. Use it to pick numbers, then
fetch them with issue_get.py.
#42 open type/task, tech/sql Wire sqlc into the repo layer
Usage:
issue_list.py [--state open|closed|all] [--label L]… [-q TEXT]
[--milestone M] [--limit N] [--page N] [--repo owner/repo]
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _tea import list_issues, repo_base, require_login # noqa: E402
def main():
ap = argparse.ArgumentParser(description="List Gitea issues (stdout only, no files)")
ap.add_argument("--state", default="open", choices=["open", "closed", "all"])
ap.add_argument("--label", action="append", default=[],
help="filter by label; repeat for AND")
ap.add_argument("-q", "--query", help="search text in title/body")
ap.add_argument("--milestone", help="milestone id or title")
ap.add_argument("--limit", type=int, default=30)
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
args = ap.parse_args()
login = require_login()
got, ms_title = list_issues(login, repo_base(args.repo), state=args.state,
labels=args.label, query=args.query,
milestone=args.milestone, limit=args.limit)
for iss in got:
labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "-"
print("#%-5d %-7s %-38s %s" % (iss["number"], iss.get("state", ""),
labels[:38], iss.get("title", "")))
scope = " in milestone %s" % ms_title if ms_title else ""
print("%d issue(s)%s — fetch them with: issue_get.py %s"
% (len(got), scope,
("--milestone %s" % args.milestone) if args.milestone else "<n>"))
if __name__ == "__main__":
main()
-188
View File
@@ -1,188 +0,0 @@
#!/usr/bin/env python3
"""
issue_push.py — create Gitea issues from local drafts, then drop the drafts.
A draft is a plain markdown file under tmp/issues/drafts/ written during
planning, with no network involved:
---
labels: [type/task, tech/sql]
---
# Wire sqlc into the appclick repo layer
## Summary
...
This script does what /tea:issue used to do by hand: validate the canonical
format, create any missing labels (exclusive for type/* and severity/*), POST
the issue, print its URL, and delete the draft — the issue now lives in Gitea,
the local copy is not a mirror and must not linger. --keep turns the draft into
a cache file (tmp/issues/<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()