refactor: split issue domain from Gitea transport
An issue was a Gitea row that happened to be cached locally: its identity was the tracker's number (42.md), its dependencies were tracker numbers (depends: [#12]), and a local issue existed only as a draft that push deleted on success. Nothing could be planned or tracked without a tracker. Split into layers, with knowledge flowing one way: skills/issue DOMAIN what an issue is: format, validation, dep graph ^ offline; stdlib imports only, no subprocess | imports skills/sync BRIDGE map.py md <-> Gitea JSON, pure, no I/O _gitea.py login pin, api, pagination, filters skills/use REFERENCE tea CLI docs for non-issue entities skills/issue never imports skills/sync. Delete the sync layer and the domain keeps working. Identity is now a slug derived from the title (wire-sqlc-appclick.md) and is stable across retitles and pushes. Tracker numbers live in a `gitea:` field, never in a file name and never in `depends:`; the pair is indexed in .remote.json, which is a cache over the files, not a second source of truth. Behavior changes: - Pushing is additive. The file is never deleted; it gains gitea:/url:/ synced: and origin: flips from local to gitea. `origin: local` is a durable state, not a pending one. - Pushes go in topological order so dependencies get numbers first. - The dependency graph is computed offline from `depends:` metadata; body prose is passed through unchanged in both directions rather than being rewritten between slugs and #N. - `origin` is domain-owned (whether work exists elsewhere is a fact about the work); the handle and how to reach it stay with sync. Script moves: issue_get.py -> sync/pull.py issue_push.py -> sync/push.py issue_list.py -> sync/remote.py issue_index.py -> issue/issue_index.py _tea.py -> split into issue/issue.py, sync/map.py, sync/_gitea.py New: issue/issue_new.py, issue/issue_check.py, issue/issue_tree.py, and sync/comment.py — comment posting was the last issue operation still hand-rolled through raw `tea api`. references/issue-format.md moves to skills/issue/references/format.md; label hex colors move out of it into map.py, since a color is how a tracker paints a chip, not what an issue is. Verified: offline path end to end (new, check, tree, index, push --dry-run) and read-only against Gitea (remote listing, pull with mapping, comment guard). Write paths of push.py and comment.py are not exercised here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+106
-54
@@ -1,73 +1,125 @@
|
||||
---
|
||||
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. Writes a local draft during planning, then pushes it with issue_push.py, which validates the format, ensures exclusive type/* labels exist, and posts via tea api.
|
||||
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 — draft locally, push when agreed
|
||||
# /tea:issue — issues as units of work
|
||||
|
||||
Thin procedure on top of the canonical format defined in
|
||||
[`../use/references/issue-format.md`](../use/references/issue-format.md).
|
||||
Read that file first — it is the single source of truth for types, labels,
|
||||
templates, and language rules.
|
||||
An issue is a markdown file in `tmp/issues/`. This skill covers everything you
|
||||
do **with** an issue: writing one, reading one, checking it against the
|
||||
canonical format, and walking the dependency graph.
|
||||
|
||||
Two phases, deliberately separated: planning writes **local files only** (no
|
||||
network, no `tea`), and one push turns them into real issues. Scripts live in
|
||||
`../use/scripts/` (see `/tea:use` for the full set).
|
||||
**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`.
|
||||
|
||||
## Phase 1 — draft (no network)
|
||||
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.
|
||||
|
||||
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. **Write `tmp/issues/drafts/<slug>.md`**: a metadata block carrying
|
||||
`labels:` only, then `# Title`, then the type's template.
|
||||
## Identity: the slug
|
||||
|
||||
```markdown
|
||||
---
|
||||
labels: [type/task, tech/sql, comp/appclick]
|
||||
---
|
||||
# Wire sqlc into the appclick repo layer
|
||||
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:`.
|
||||
|
||||
## Summary
|
||||
...
|
||||
```
|
||||
Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to
|
||||
issues by id.
|
||||
|
||||
English imperative title with no type prefix; every section of the
|
||||
template present and in order; headers English, prose Russian; `## Spec`
|
||||
filled with a repo path, a URL, or the literal `none` — ask the user if you
|
||||
cannot determine which. Add `## Depends on` right after `## Spec` when the
|
||||
issue depends on others (one `#N` per line); omit it otherwise.
|
||||
One draft file = one issue. Several related issues = several drafts.
|
||||
4. **Check the format without posting** (optional, free):
|
||||
```bash
|
||||
python3 ../use/scripts/issue_push.py --all --dry-run
|
||||
```
|
||||
## Scripts
|
||||
|
||||
## Phase 2 — push (once the plan is agreed)
|
||||
All offline, all in `<skill-base-dir>/scripts/`.
|
||||
|
||||
```bash
|
||||
python3 ../use/scripts/issue_push.py --all
|
||||
| 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 |
|
||||
|
||||
```
|
||||
tmp/issues/INDEX.md table of every issue — read this first
|
||||
tmp/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
|
||||
tmp/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
|
||||
tmp/issues/tree-<id>.md saved graph (issue_tree.py --write)
|
||||
```
|
||||
|
||||
The script validates the format (exactly one `type/*`, at most one
|
||||
`severity/*`, English title, `## Summary` / `## Spec` / `## Acceptance
|
||||
criteria` present), creates any missing labels — `exclusive: true` for
|
||||
`type/*` and `severity/*`, canonical colors from the format doc — POSTs each
|
||||
issue, prints `#N <url>`, and **deletes the draft**. The issue lives in Gitea
|
||||
now; the local copy is not a mirror and must not linger.
|
||||
## Reading: grep, don't parse
|
||||
|
||||
Flags: `--keep` writes `tmp/issues/<n>.md` instead of deleting, `--dry-run`
|
||||
validates only, `--force` posts despite format violations (say why).
|
||||
Metadata is one field per line with inline lists precisely so plain `grep`
|
||||
works. `INDEX.md` first, then the files:
|
||||
|
||||
Report the issue URLs and the applied labels to the user.
|
||||
```bash
|
||||
grep -l 'labels:.*type/bug' tmp/issues/*.md # all bugs
|
||||
grep -l 'origin: local' tmp/issues/*.md # never pushed anywhere
|
||||
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
|
||||
```
|
||||
|
||||
## Editing an existing issue
|
||||
Read whole files only for the issues the task actually needs.
|
||||
|
||||
Drafts only create. To bring an existing issue to the format: fetch it with
|
||||
`python3 ../use/scripts/issue_get.py <n>` (writes `tmp/issues/<n>.md`,
|
||||
prints a compact line), restructure the body into the type's template without
|
||||
losing information, then `PATCH repos/{owner}/{repo}/issues/{n}` via `tea api`
|
||||
with the new title/body and ensure exactly one `type/*` label is set. Login is
|
||||
always the placeholder `--login "$GITEA_LOGIN"` (see `/tea:use`).
|
||||
## 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`.
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
# Issue format
|
||||
|
||||
Canonical format for every issue in this project, whether it ever reaches a
|
||||
tracker or not. Designed to be unambiguous for both humans and LLMs: fixed
|
||||
English section headers in a fixed order, verifiable acceptance criteria, one
|
||||
issue = one deliverable. Source spec: the project wiki
|
||||
([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
|
||||
|
||||
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
|
||||
|
||||
- **Issue title**: English, imperative mood, no type prefix — the type lives in
|
||||
the label, not the title. Good: `Fix tea-guard crash on empty settings file`.
|
||||
Bad: `fix: crash`, `[bug] crash`, `Крашится гвард`.
|
||||
- **Section headers**: the exact English literals below, as `##` headings, in
|
||||
the given order. Do not translate, rename, or reorder them.
|
||||
- **Body prose** (text inside sections): Russian.
|
||||
|
||||
## Label namespaces
|
||||
|
||||
Four namespaces classify an issue. Two are exclusive (at most one label from
|
||||
the namespace), two are free-form:
|
||||
|
||||
| Namespace | Exclusive | Purpose |
|
||||
|---|---|---|
|
||||
| `type/*` | yes | What kind of work; primarily its business value. Mandatory, exactly one. |
|
||||
| `severity/*` | yes | Business impact. At most one; apply when the impact is known. |
|
||||
| `tech/*` | no | Technology the issue is bound to. Any number. |
|
||||
| `comp/*` | no | System component of this repo. Any number; no preset — project-specific. |
|
||||
|
||||
### `type/*` — mandatory, exactly one
|
||||
|
||||
| Label | Meaning |
|
||||
|---|---|
|
||||
| `type/bug` | Something behaves incorrectly in existing code |
|
||||
| `type/task` | Implementation of new functionality |
|
||||
| `type/refactor` | Internal restructuring: file moves, architecture; behavior must not change |
|
||||
| `type/test` | Writing or fixing tests |
|
||||
| `type/feature` | Container: several issues delivering one unit of business value |
|
||||
| `type/draft` | Idea captured for later; not ready for work |
|
||||
|
||||
### `severity/*` — at most one
|
||||
|
||||
`severity/low`, `severity/medium`, `severity/high`, `severity/showstopper`,
|
||||
`severity/critical`.
|
||||
|
||||
### `tech/*` — any number
|
||||
|
||||
Technology-bound labels, e.g. `tech/sql` (pgx, sqlc, sql-migrate — persistent
|
||||
storage), `tech/obs` (grafana, loki, prometheus, alloy — observability),
|
||||
`tech/postgres`.
|
||||
|
||||
### `comp/*` — any number
|
||||
|
||||
Components of this repo's system, e.g. `comp/appclick`. No preset list —
|
||||
derive from the project.
|
||||
|
||||
> 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`
|
||||
> and are applied on push.
|
||||
|
||||
## Dependencies
|
||||
|
||||
`depends:` in the metadata block is the graph, and it holds **ids**:
|
||||
|
||||
```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
|
||||
## Depends on
|
||||
- migrate-schema — нужна схема БД из этого issue
|
||||
- add-pool-cfg
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- `## Summary` is always the first section; `## Acceptance criteria` is always
|
||||
present (exception: `type/draft`). These two are the anchors every reader
|
||||
(human or LLM) relies on.
|
||||
- `## Spec` is **mandatory in every type**. Its value is a repo path
|
||||
(`docs/specs/auth.md`), a URL, or the literal `none` when no spec exists.
|
||||
Never omit the section and never invent a link — `none` is an explicit,
|
||||
valid answer.
|
||||
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
|
||||
checkable condition, not an aspiration.
|
||||
- Code references use the `path/file.ext:line` form; related issues by id.
|
||||
- Screenshots are allowed but their content must be duplicated as text — an
|
||||
LLM reading these files cannot see images.
|
||||
- If acceptance criteria grow past ~5 unrelated items, split the issue (or
|
||||
promote it to a `type/feature` container with child issues).
|
||||
|
||||
## Template: `type/bug`
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Что сломано и где проявляется, одно-два предложения.
|
||||
|
||||
## Spec
|
||||
`docs/specs/auth.md`, URL — или `none`.
|
||||
|
||||
## Steps to reproduce
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Expected
|
||||
Что должно было произойти.
|
||||
|
||||
## Actual
|
||||
Что происходит на самом деле: вывод команды, лог.
|
||||
|
||||
## Environment
|
||||
Только релевантное: версии, ОС, конфигурация.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] баг не воспроизводится по шагам выше
|
||||
- [ ] добавлена проверка на регрессию (если применимо)
|
||||
```
|
||||
|
||||
## Template: `type/task`
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Что нужно сделать, одно-два предложения.
|
||||
|
||||
## Spec
|
||||
Ссылка или `none`.
|
||||
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
- [ ] …
|
||||
|
||||
## Constraints
|
||||
Что НЕ входит в объём; технические рамки. (опционально)
|
||||
```
|
||||
|
||||
## Template: `type/refactor`
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Что перестраиваем и в каких файлах (`path/file:line`).
|
||||
|
||||
## Spec
|
||||
Ссылка или `none`.
|
||||
|
||||
## Motivation
|
||||
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
||||
|
||||
## Invariants
|
||||
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
||||
```
|
||||
|
||||
## Template: `type/test`
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Что покрываем тестами и где (`path/file:line`).
|
||||
|
||||
## Spec
|
||||
Ссылка или `none`.
|
||||
|
||||
## Motivation
|
||||
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
||||
|
||||
## Test cases
|
||||
- сценарий → ожидаемый результат
|
||||
- …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] перечисленные кейсы покрыты и зелёные
|
||||
- [ ] тесты проходят в CI
|
||||
```
|
||||
|
||||
## Template: `type/feature`
|
||||
|
||||
A container: one unit of business value delivered by several child issues.
|
||||
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and link back
|
||||
via their `depends:`. Keep implementation detail in the children; the feature
|
||||
body stays at business level.
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Бизнес-ценность одним-двумя предложениями.
|
||||
|
||||
## Spec
|
||||
Ссылка или `none`.
|
||||
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Issues
|
||||
- [ ] wire-sqlc-appclick — краткое описание части
|
||||
- [ ] …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] все дочерние issues закрыты
|
||||
- [ ] проверяемое условие уровня фичи (например, e2e-сценарий работает)
|
||||
```
|
||||
|
||||
## Template: `type/draft`
|
||||
|
||||
A parking spot for ideas that are not fleshed out yet. Minimal structure, no
|
||||
acceptance criteria required. Before implementation starts, a draft MUST be
|
||||
promoted: relabeled to a concrete type and rewritten into that type's template.
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Идея одним-двумя предложениями.
|
||||
|
||||
## Spec
|
||||
Ссылка или `none` (для драфтов обычно `none`).
|
||||
|
||||
## Notes
|
||||
Свободные заметки: что известно, открытые вопросы, варианты.
|
||||
```
|
||||
|
||||
## Containers beyond `type/feature`
|
||||
|
||||
- **Milestone** — a set of issues with an optional time bound. Locally it is
|
||||
just the `milestone:` field; a tracker-side milestone must already exist for
|
||||
a push to attach the issue to it.
|
||||
- **Project** — a set of issues tracked by status columns (Backlog, ToDo,
|
||||
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()
|
||||
Reference in New Issue
Block a user