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