Compare commits
18 Commits
3549f37ebf
...
2ac301550e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac301550e | |||
| e629d14585 | |||
| 1815d91cdf | |||
| 257c547e22 | |||
| d4c43464e5 | |||
| 47f53a7edc | |||
| 484da64621 | |||
| 31f7c39155 | |||
| f230f98f35 | |||
| b72f619fda | |||
| 62c8ff976d | |||
| fb862554ed | |||
| 6d01ead245 | |||
| 0cf4baa429 | |||
| d8bd927f1d | |||
| 9234d8004f | |||
| 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, the tea-runner subagent executes the scripts on a cheap model, 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 and wiki pages as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph) and /tea:page turns a discussion's artifacts into a titled, ordered page tree; /tea:sync and /tea:wiki move each to and from Gitea; /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.",
|
||||||
"version": "1.1.0",
|
"version": "2.2.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "naudachu"
|
"name": "naudachu"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": ["gitea", "tea", "cli", "git", "login-guard"]
|
"keywords": ["gitea", "cli", "git", "issues", "wiki", "login-guard"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,184 @@
|
|||||||
|
|
||||||
## 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. Two domains, two bridges, one transport, and
|
||||||
|
knowledge flows one way only:
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||||
|
skills/page DOMAIN what a page tree is: title <-> path, order, the index
|
||||||
|
▲ offline — no tracker, no network, stdlib imports only
|
||||||
|
│ imports
|
||||||
|
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
||||||
|
_gitea.py login pin, tea api, pagination, filters
|
||||||
|
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
|
||||||
|
transport is _gitea.py — there is no second one
|
||||||
|
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
|
||||||
|
▲
|
||||||
|
│ calls
|
||||||
|
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
||||||
|
```
|
||||||
|
|
||||||
|
A domain never imports its bridge, and the two domains do not import each
|
||||||
|
other: delete `skills/sync` and issues still work, delete `skills/wiki` and page
|
||||||
|
trees still work, delete either domain and the other is untouched. The check is
|
||||||
|
mechanical — every import under a domain's `scripts/` is stdlib, and
|
||||||
|
`subprocess` is not among them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
||||||
|
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
|
||||||
|
```
|
||||||
|
|
||||||
|
If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
||||||
|
`content_base64`) shows up in a 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, body checkboxes
|
||||||
|
- `scripts/issue_new.py` — create a local issue from its type template
|
||||||
|
- `scripts/issue_check.py` — validate against the format
|
||||||
|
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
|
||||||
|
substring, changing exactly one character of the file
|
||||||
|
- `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/page` — a discussion's artifacts as a page tree (`/tea:page`),
|
||||||
|
entirely offline
|
||||||
|
- `references/pages.md` — canonical page-tree format; single source of truth
|
||||||
|
- `scripts/page.py` — domain module: title ↔ path, ordering, the manifest,
|
||||||
|
importing a directory of markdown, the index
|
||||||
|
- `scripts/page_import.py` — copy a directory of markdown into a space,
|
||||||
|
titling every file
|
||||||
|
- `scripts/page_index.py` — write the table-of-contents page
|
||||||
|
- `scripts/page_ls.py` — the tree, the titles, one sync-state tag per page
|
||||||
|
- `skills/wiki` — move page trees between a local space and a Gitea wiki
|
||||||
|
(`/tea:wiki`)
|
||||||
|
- `scripts/wikimap.py` — md ↔ Gitea wiki JSON, pure, no I/O
|
||||||
|
- `scripts/wiki_ls.py`, `wiki_pull.py`, `wiki_push.py` — transport is
|
||||||
|
`skills/sync/scripts/_gitea.py`
|
||||||
|
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
||||||
|
(`/tea:use`); `references/tea/` holds the command docs
|
||||||
|
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
||||||
|
returns a compact receipt. Delegate batches (bulk pull, push a named set,
|
||||||
|
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
|
||||||
|
and no `Write`, may not `--force`, and may not decide what an issue says.
|
||||||
|
Delegating a single call costs more than running it inline — the win is the
|
||||||
|
loop, the retry, and the error triage.
|
||||||
|
- `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)
|
||||||
|
- `tests/` — stdlib `unittest`, no third-party anything
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Plain `unittest`; no pytest, no dependencies — the scripts under test are
|
||||||
|
stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
|
||||||
|
packages, so a test that needs the domain module imports it with
|
||||||
|
`sys.path.insert`.
|
||||||
|
|
||||||
|
**A test never touches `tmp/issues/` or `tmp/wiki/`.** Anything that needs a
|
||||||
|
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
||||||
|
`.git` marker, a copy of the script layers, fixture issues or artifacts — and
|
||||||
|
runs the real scripts inside it as subprocesses. That is the only way to test
|
||||||
|
behavior that depends on where a script is run from, and it keeps the
|
||||||
|
developer's own store out of the blast radius.
|
||||||
|
|
||||||
|
## Local issue store
|
||||||
|
|
||||||
|
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
|
||||||
|
store.** An `origin: local` issue lives here and nowhere else — this file *is*
|
||||||
|
the issue, and losing it loses the work. Anything with `origin: gitea` is a
|
||||||
|
**cache**: the tracker has it, this copy is a working copy, and it is deleted
|
||||||
|
the moment a push confirms the tracker is up to date.
|
||||||
|
|
||||||
|
One flat markdown file per issue, named by its slug, with one metadata field per
|
||||||
|
line so plain grep works without a parser.
|
||||||
|
|
||||||
|
- **The path is `<repo root>/tmp/issues`, resolved from `issue.py`'s own
|
||||||
|
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
|
||||||
|
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
|
||||||
|
whatever directory it is run from. An explicit `--out` overrides it and is
|
||||||
|
used exactly as typed; a relative `--out` stays relative to cwd.
|
||||||
|
- Nothing creates the store as a side effect of a write. Readers distinguish
|
||||||
|
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
|
||||||
|
and they say so on stderr.
|
||||||
|
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
|
||||||
|
Numbers live in the `gitea:` field.
|
||||||
|
- `origin: local` is a complete state, not a draft: an issue that never leaves
|
||||||
|
this machine is valid and finished. It is not a *durable* state, though —
|
||||||
|
pushing ends it, and the local file goes with it.
|
||||||
|
- **A successful push deletes the local file** (`<id>.md` and
|
||||||
|
`<id>.comments.md`), and prints the number and URL the issue now lives at.
|
||||||
|
`--update` too: one rule, no exception. What is in the store is what has not
|
||||||
|
left. Get it back with `pull.py <n>`.
|
||||||
|
- Deletion happens only after a confirmed tracker response and only after
|
||||||
|
`.remote.json` has been written. Network down, non-2xx, an answer that does
|
||||||
|
not carry the right number: the file stays and the run stops. A never-pushed
|
||||||
|
`origin: local` issue is never touched by any of this.
|
||||||
|
- The slug survives the round trip because it goes up in the body as
|
||||||
|
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
|
||||||
|
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
|
||||||
|
fresh clone, another machine — the file comes back under the same name and
|
||||||
|
every `depends:` that points at it still resolves.
|
||||||
|
- `.remote.json` is therefore no longer "an index over the files": it is the
|
||||||
|
local number → slug ledger, its entries outlive the files they name, and
|
||||||
|
nothing prunes them. It is still recoverable — from the markers in Gitea, not
|
||||||
|
from the files.
|
||||||
|
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
|
||||||
|
issue comes back at all.
|
||||||
|
- No drift tracking, and now nothing to track: there is no second copy to
|
||||||
|
diverge from. `synced:` tells you how old your working copy is.
|
||||||
|
|
||||||
|
## Local wiki cache
|
||||||
|
|
||||||
|
`tmp/wiki/<space>/` (gitignored) holds page trees — a discussion's artifacts,
|
||||||
|
organized. Same stance as the issue store, resolved the same way from
|
||||||
|
`page.py`'s own location, with the same `--out` rule.
|
||||||
|
|
||||||
|
- Identity is the **title**, and `/` inside it is the only hierarchy there is.
|
||||||
|
The Gitea wiki is flat: it escapes a title into one filename by rules of its
|
||||||
|
own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`).
|
||||||
|
- **`sub_url` is Gitea's address for a page and is never constructed.** It is
|
||||||
|
read back from the API and stored in `.pages.json`. One built by hand that is
|
||||||
|
almost right creates a second page instead of editing the first.
|
||||||
|
- **Never commit a subdirectory into a wiki's git repository.** Gitea does not
|
||||||
|
see it — the page exists on disk and nowhere in the API or the UI. Do not
|
||||||
|
clone the wiki repo to work in; use the scripts.
|
||||||
|
- A title is a decision, not a derivation. A re-import replaces bodies and
|
||||||
|
keeps titles, so editing a heading cannot silently rename a published page.
|
||||||
|
`--retitle` opts in, and the rename reaches the wiki on the next push.
|
||||||
|
- A page with no `sub_url` has never been published — a complete state, the way
|
||||||
|
`origin: local` is for an issue. **The parallel stops at the push**: a pushed
|
||||||
|
page stays on disk, a pushed issue does not.
|
||||||
|
- Change detection is one hash (`pushed`). Pulling overwrites; pushing is
|
||||||
|
additive and never deletes — the one place the two domains deliberately
|
||||||
|
disagree, because a page tree is worked on locally and an issue is not.
|
||||||
|
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
|
||||||
|
`_gitea.py`.
|
||||||
|
|||||||
@@ -7,10 +7,32 @@ 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-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
|
||||||
| `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
|
||||||
|
▲
|
||||||
|
│ calls
|
||||||
|
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
||||||
|
```
|
||||||
|
|
||||||
|
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 +62,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 +74,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
|
||||||
|
|
||||||
@@ -67,19 +91,83 @@ This prevents silent fallback to the machine's default login (often a personal a
|
|||||||
|
|
||||||
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
|
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
|
||||||
|
|
||||||
|
## The tea-runner agent
|
||||||
|
|
||||||
|
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on
|
||||||
|
Haiku that does the second half in its own context and hands back a receipt —
|
||||||
|
what ran, what it touched, what failed, verbatim.
|
||||||
|
|
||||||
|
Delegate a **batch**: pull a milestone and rebuild the index, push the three
|
||||||
|
issues you just wrote, bootstrap the label set, post a comment from a file you
|
||||||
|
prepared. Spawning it for a single `pull.py 42` costs more than running the
|
||||||
|
command yourself; the saving is in the loop, the retry, and reading somebody
|
||||||
|
else's stderr.
|
||||||
|
|
||||||
|
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
|
||||||
|
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
|
||||||
|
type, a failed validation, an unpushed dependency come back as a question, not
|
||||||
|
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
|
||||||
|
session — the pinned login is enforced on every call it makes.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
.claude-plugin/
|
.claude-plugin/
|
||||||
plugin.json plugin manifest
|
plugin.json plugin manifest
|
||||||
marketplace.json marketplace catalog (makes `/plugin install` work)
|
marketplace.json marketplace catalog (makes `/plugin install` work)
|
||||||
|
agents/
|
||||||
|
tea-runner.md subagent (Haiku) that executes the scripts
|
||||||
hooks/
|
hooks/
|
||||||
hooks.json registers the PreToolUse hook
|
hooks.json registers the PreToolUse hook
|
||||||
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,
|
||||||
|
body checkboxes
|
||||||
|
issue_new.py create a local issue from its type template
|
||||||
|
issue_check.py validate against the format
|
||||||
|
issue_ac.py list the body's checkboxes; tick one
|
||||||
|
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, then drops the local file
|
||||||
|
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.
|
||||||
|
|
||||||
|
An `origin: local` file **is** the issue — the store, and the only copy.
|
||||||
|
Anything with `origin: gitea` is a working copy of something the tracker
|
||||||
|
already has, and it is deleted as soon as a push confirms the tracker is up to
|
||||||
|
date:
|
||||||
|
|
||||||
|
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
|
||||||
|
live in a `gitea:` field.
|
||||||
|
- `origin: local` is a complete state. An issue that never leaves your machine
|
||||||
|
is valid and finished — but it is not permanent: pushing ends it.
|
||||||
|
- **A successful push deletes the local file** (`--update` too) and prints the
|
||||||
|
number and URL it now lives at. Only after a confirmed response: a failed
|
||||||
|
call leaves the file exactly where it was. Get it back with `pull.py <n>` —
|
||||||
|
same slug, same `depends:`, even after a rename in Gitea.
|
||||||
|
- Pulling overwrites the body: a fetch, not a merge. It is also how a pushed
|
||||||
|
issue comes back.
|
||||||
|
- Nothing tracks drift, and there is no second copy to drift. A file that is
|
||||||
|
still here has not been pushed.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
name: tea-runner
|
||||||
|
description: Executes the tea plugin's scripts and reports back a compact receipt. Use for the mechanical half of tracker work — bulk pulls, pushing issues the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
|
||||||
|
tools: Bash, Read, Grep, Glob, Skill
|
||||||
|
model: haiku
|
||||||
|
---
|
||||||
|
|
||||||
|
# tea-runner — the execution layer
|
||||||
|
|
||||||
|
You run this project's issue scripts and hand back a short receipt. You are the
|
||||||
|
fourth layer of the plugin, below the three that carry meaning:
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/issue DOMAIN what an issue is
|
||||||
|
skills/sync BRIDGE md <-> Gitea, over the wire
|
||||||
|
skills/use REFERENCE tea CLI docs
|
||||||
|
▲
|
||||||
|
│ calls
|
||||||
|
tea-runner EXECUTION runs the scripts, reports the result
|
||||||
|
```
|
||||||
|
|
||||||
|
Knowledge still flows one way. You call those layers; nothing in them knows you
|
||||||
|
exist. **You hold no opinion about content.** Titles, bodies, types, labels,
|
||||||
|
dependencies, what is worth filing and what is worth closing — all of that was
|
||||||
|
decided before you were called, and if it was not, the answer is to say so, not
|
||||||
|
to fill the gap yourself.
|
||||||
|
|
||||||
|
## Where the commands come from
|
||||||
|
|
||||||
|
Load the skill, do not remember the flags:
|
||||||
|
|
||||||
|
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `remote.py`, `labels.py`
|
||||||
|
- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`,
|
||||||
|
`issue_new.py`, `issue_ac.py`
|
||||||
|
- `/tea:wiki` — `wiki_ls.py`, `wiki_pull.py`, `wiki_push.py`
|
||||||
|
- `/tea:page` — `page_import.py`, `page_index.py`, `page_ls.py`
|
||||||
|
|
||||||
|
Invoke `Skill` with the one that owns the task at the start, and use the command
|
||||||
|
table it gives you verbatim. The skill is the single source of
|
||||||
|
truth for the script surface; a flag you recall from another session is a
|
||||||
|
guess. If the skill does not document a flag, it does not exist — report that
|
||||||
|
instead of trying it.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
1. **No raw `tea`.** Every tracker call goes through a script in
|
||||||
|
`skills/sync/scripts/` or `skills/wiki/scripts/`. The one exception is a
|
||||||
|
diagnostic the skill itself
|
||||||
|
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
|
||||||
|
the `tea-guard` hook substitutes the pinned login. Never name a login.
|
||||||
|
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
|
||||||
|
write files; you do not. If a task needs a body edited or a metadata field
|
||||||
|
changed by hand, stop and say which file and which field. `issue_ac.py` is
|
||||||
|
the one script that touches a body, and it changes a single character: tick
|
||||||
|
only the items the caller named, by the number or the substring the caller
|
||||||
|
gave. Whether a criterion is actually met is a judgement about content, and
|
||||||
|
content is never yours.
|
||||||
|
3. **Push only what you were told to push.** `push.py` and `wiki_push.py`
|
||||||
|
publish to a tracker other people read, **and `push.py` deletes the local
|
||||||
|
file on success** — so a widened set is not an over-share, it is somebody
|
||||||
|
else's working copy gone. Run them with the ids, titles, or filter the
|
||||||
|
caller named. Never widen the set, never run a bare `push.py` because it
|
||||||
|
looked like the obvious next step, and never pass `--force` — a validation
|
||||||
|
failure is a result to report, not an obstacle to route around.
|
||||||
|
`wiki_push.py` needs `-m`; use the caller's words, never your own summary.
|
||||||
|
Report the number and URL `push.py` printed; that is now the only address
|
||||||
|
the issue has.
|
||||||
|
4. **Do not close, delete, or retitle anything** on either side. On the wiki
|
||||||
|
that means no `--retitle`: renaming a published page abandons the old one.
|
||||||
|
The one deletion you may cause is push's own, on the issue you were told to
|
||||||
|
push.
|
||||||
|
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
||||||
|
permute flags looking for one that works.
|
||||||
|
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
|
||||||
|
issue body back into your report. The scripts print compact output by
|
||||||
|
design; the caller reads the files it needs from disk.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Load the skill you need.
|
||||||
|
2. Run the commands. Prefer one filtered call over a loop —
|
||||||
|
`pull.py --milestone 6` is one request per 50 issues, `pull.py 41 42 43…`
|
||||||
|
is one per issue.
|
||||||
|
3. If a command exits non-zero, capture the last lines of stderr and stop that
|
||||||
|
branch. Keep going on independent branches.
|
||||||
|
4. Report.
|
||||||
|
|
||||||
|
## Report format
|
||||||
|
|
||||||
|
Your final message is the return value. Keep it under ~20 lines. No preamble,
|
||||||
|
no restatement of the request, no advice about what to do next.
|
||||||
|
|
||||||
|
```
|
||||||
|
ran:
|
||||||
|
pull.py --milestone 6 --state all ok 7 issues, 3 threads
|
||||||
|
issue_index.py ok INDEX.md rebuilt
|
||||||
|
push.py wire-sqlc-appclick FAIL exit 1
|
||||||
|
|
||||||
|
touched: tmp/issues/{a,b,c}.md, tmp/issues/INDEX.md
|
||||||
|
|
||||||
|
failed: push.py wire-sqlc-appclick
|
||||||
|
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
|
||||||
|
|
||||||
|
blocked: none
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
|
||||||
|
- `touched` — paths only. Never contents.
|
||||||
|
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
|
||||||
|
the cause. Quote it exactly; do not paraphrase an error.
|
||||||
|
- `blocked` — what you refused to decide, phrased as the question the caller
|
||||||
|
has to answer. `none` when there is nothing.
|
||||||
|
|
||||||
|
## Known stops
|
||||||
|
|
||||||
|
Report these and halt; none of them is yours to resolve.
|
||||||
|
|
||||||
|
| Condition | Report |
|
||||||
|
|---|---|
|
||||||
|
| no login pinned (`tea-guard` blocks, or a script points at `/tea:auth`) | `blocked: no pinned login — operator must run /tea:auth` |
|
||||||
|
| `issue_check.py` errors before a push | the validator's own lines, verbatim |
|
||||||
|
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
|
||||||
|
| a milestone or label does not exist in the repo | the script prints the real ones — pass that list through |
|
||||||
|
| a script asks for a decision (type, label, `--force`) | `blocked:` with the question |
|
||||||
+221
-47
@@ -1,57 +1,231 @@
|
|||||||
---
|
---
|
||||||
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_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
|
||||||
|
| `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
|
## Where the store is
|
||||||
|
|
||||||
When asked to bring an existing issue to the format: fetch it with the use
|
`<repo root>/tmp/issues` — **not** `tmp/issues` relative to wherever you are
|
||||||
skill's script (`python3 ../use/scripts/fetch_issue.py <n>` relative to this
|
standing. The scripts resolve it by walking up from their own file to the
|
||||||
skill's base dir — writes `tmp/issue/<n>/data` + comments, prints a compact
|
nearest `.git` or `AGENTS.md`, so they all see one store no matter which
|
||||||
index; no `--login`, it resolves the pin itself), restructure the body into
|
directory you run them from, and a `cd` earlier in the session changes nothing.
|
||||||
the type's template without losing information, then
|
|
||||||
`PATCH repos/{owner}/{repo}/issues/{n}` with the new title/body and ensure
|
`--out` overrides that and is taken **literally**: an absolute path is used as
|
||||||
exactly one `type/*` label is set.
|
given, a relative one stays relative to the current directory. Nothing rewrites
|
||||||
|
what you typed.
|
||||||
|
|
||||||
|
Two things follow, and both are deliberate:
|
||||||
|
|
||||||
|
- A store that is not there reports `does not exist`; a store with no issues in
|
||||||
|
it reports `is empty`. They are different problems.
|
||||||
|
- No script conjures a store as a side effect of writing. Only `issue_new.py`
|
||||||
|
creates one — the first issue in a fresh checkout — and it says so on stderr.
|
||||||
|
|
||||||
|
## Reading: grep, don't parse
|
||||||
|
|
||||||
|
Metadata is one field per line with inline lists precisely so plain `grep`
|
||||||
|
works. `INDEX.md` first, then the files:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
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:`, add ids to
|
||||||
|
`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh
|
||||||
|
the table. Checkboxes are the exception — use `issue_ac.py`, below.
|
||||||
|
|
||||||
|
If the issue is synced (`origin: gitea`), the file is a working copy: your edit
|
||||||
|
is local until you run `push.py --update` from `/tea:sync`, and that push
|
||||||
|
**deletes the file** once Gitea has it. Nothing tracks drift, and with one copy
|
||||||
|
at a time there is little to track — a file that is still here has not been
|
||||||
|
pushed. Get it back with `pull.py <n>`; the slug does not change.
|
||||||
|
|
||||||
|
## Ticking checkboxes
|
||||||
|
|
||||||
|
A checkbox is the one part of a body that is **state** and not prose, so it has
|
||||||
|
a command of its own. Never rewrite a body just to tick a box: the rewrite
|
||||||
|
re-flows lines and re-words sentences, and the issue's diff swells around a
|
||||||
|
change that means one character.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick
|
||||||
|
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check 3
|
||||||
|
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check "регресс"
|
||||||
|
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --uncheck 3
|
||||||
|
```
|
||||||
|
|
||||||
|
With no flag it prints the numbered list with each item's state, grouped by the
|
||||||
|
heading the item sits under. `--check` / `--uncheck` take that number or a
|
||||||
|
substring of the item's text (case-insensitive).
|
||||||
|
|
||||||
|
- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A
|
||||||
|
`type/feature` keeps its children as checkboxes under `## Issues`, and they
|
||||||
|
are numbered in the same list. The script is named after the section most
|
||||||
|
boxes live in, nothing more.
|
||||||
|
- **A substring must match exactly one item.** Two matches is an error that
|
||||||
|
lists them; pick by number instead. It never guesses.
|
||||||
|
- **Exactly one character of the file changes.** Metadata, wording, wrapping
|
||||||
|
and trailing whitespace all come back byte for byte, so `git diff` and the
|
||||||
|
tracker's diff show the tick and nothing else.
|
||||||
|
- Examples inside a ``` fence are markup, not state — they are skipped.
|
||||||
|
- `INDEX.md` gains a `progress` column (`3/7`, blank when the issue has no
|
||||||
|
boxes), recomputed from the body on every build and stored in no field.
|
||||||
|
`issue_ac.py` rebuilds the index after a successful tick.
|
||||||
|
|
||||||
|
Getting the tick to the tracker is a separate step — `push.py --update` in
|
||||||
|
`/tea:sync`.
|
||||||
|
|
||||||
|
## Writing a proper description
|
||||||
|
|
||||||
|
Issues get filed on the run — "comments aren't pulled", "the guard broke".
|
||||||
|
That is a request, not a statement of work: no reproduction steps, no
|
||||||
|
`path/file:line`, acceptance criteria nobody can check. Rewriting one into the
|
||||||
|
canonical format is a procedure, not improvisation.
|
||||||
|
|
||||||
|
1. **Read the issue whole**, and everything it points at — the ids in
|
||||||
|
`depends:`, the `## Spec` target, the files it names.
|
||||||
|
2. **Determine the type and its template.** The `type/*` label selects one of
|
||||||
|
the templates in [`references/format.md`](references/format.md), and that
|
||||||
|
template's section list is the shape you are aiming at. If the label is
|
||||||
|
missing or wrong, decide it now and fix `labels:`; promoting a `type/draft`
|
||||||
|
to a concrete type is this same step.
|
||||||
|
3. **Locate the anchor points in the code.** Grep the repo for every file,
|
||||||
|
symbol, command, and error string the issue mentions, until you can name
|
||||||
|
lines:
|
||||||
|
```bash
|
||||||
|
grep -rn 'GITEA_LOGIN' hooks/ skills/
|
||||||
|
```
|
||||||
|
Work that does not exist yet still has anchor points — the files the change
|
||||||
|
will land in, and the ones that will call it.
|
||||||
|
4. **Gather the missing context.** What has to be there when you are done:
|
||||||
|
- code references in the `path/file.ext:line` form, for every place the
|
||||||
|
change lands;
|
||||||
|
- reproduction steps — exact commands and their real output (`type/bug`
|
||||||
|
splits them across `## Steps to reproduce` / `## Expected` / `## Actual`);
|
||||||
|
- acceptance criteria that are objectively checkable: a command that exits
|
||||||
|
0, a file that exists, a section that is present — not aspirations;
|
||||||
|
- a real value for `## Spec` — a repo path, a URL, or the literal `none`.
|
||||||
|
|
||||||
|
**A missing fact is either found in the repository or becomes a question to
|
||||||
|
the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in
|
||||||
|
`## Spec` as the legitimate answer it is — never a plausible-looking link.
|
||||||
|
5. **Rewrite the sections** with Edit: every section of the template, in the
|
||||||
|
template's order, English headers and Russian prose. Replace the body; do
|
||||||
|
not append a second telling of the same issue below the old one.
|
||||||
|
6. **Check it:**
|
||||||
|
```bash
|
||||||
|
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
||||||
|
```
|
||||||
|
Errors mean malformed, warnings mean the type's template is not fully
|
||||||
|
filled in. Re-run `issue_index.py` if the labels changed.
|
||||||
|
|
||||||
|
The procedure is identical for `origin: local` and `origin: gitea` — it works
|
||||||
|
on `tmp/issues/<id>.md`, and this layer does not know the difference. Getting
|
||||||
|
the rewritten body into the tracker is a separate decision — `push.py --update`
|
||||||
|
in `/tea:sync` — and is no part of this.
|
||||||
|
|
||||||
|
## 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,370 @@
|
|||||||
|
# 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; an issue pushed to a tracker, deleted locally and fetched back a month
|
||||||
|
later keeps it too. Tracker numbers are a foreign key stored in a field, never
|
||||||
|
the name of anything.
|
||||||
|
|
||||||
|
```
|
||||||
|
tmp/issues/wire-sqlc-appclick.md
|
||||||
|
```
|
||||||
|
|
||||||
|
A slug never contains a dot, which is how the store tells an issue from the
|
||||||
|
files parked beside it (`<id>.comments.md`).
|
||||||
|
|
||||||
|
Stability is a promise the format makes, so something has to keep it once the
|
||||||
|
file is gone. That is the sync layer's problem and its answer is a marker in the
|
||||||
|
body — see `/tea:sync`; the domain neither writes nor reads it, and it never
|
||||||
|
appears in the file on disk.
|
||||||
|
|
||||||
|
## 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]
|
||||||
|
wiki: [Simple Chains/Ideas/Chain core]
|
||||||
|
origin: gitea
|
||||||
|
branch: feat/wire-sqlc
|
||||||
|
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** |
|
||||||
|
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them |
|
||||||
|
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
||||||
|
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
|
||||||
|
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
|
||||||
|
| `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 **complete state, not a pending one.** An issue that never
|
||||||
|
leaves this machine is valid and finished work; pushing it is optional and
|
||||||
|
nothing here treats it as a draft.
|
||||||
|
|
||||||
|
It is not a *permanent* state, and this is the one place where the file's fate
|
||||||
|
depends on it:
|
||||||
|
|
||||||
|
| `origin:` | what the file is | what a push does to it |
|
||||||
|
|---|---|---|
|
||||||
|
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file |
|
||||||
|
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file |
|
||||||
|
|
||||||
|
**A successful push deletes `tmp/issues/<id>.md`** (and `<id>.comments.md`), on
|
||||||
|
create and on `--update` alike. What is in the store is what has not left this
|
||||||
|
machine; everything else is fetched again when it is needed. The rule, its
|
||||||
|
safety conditions, and how the slug survives are `/tea:sync`'s to state.
|
||||||
|
|
||||||
|
The `id` never changes across that round trip, which is why `depends:` in other
|
||||||
|
issues keeps working. That is the format's promise; the mechanism is not.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
A `type/feature` container writes the same relation under `## Issues` instead
|
||||||
|
(see the template below). Same direction, same rule: every id named there also
|
||||||
|
belongs in that issue's `depends:`. The warning names whichever of the two
|
||||||
|
sections the reference actually came from.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- A checkbox is **item markup, not a property of one section**: `- [ ]`
|
||||||
|
unticked, `- [x]` ticked, and it means the same under `## Issues` as under
|
||||||
|
`## Acceptance criteria`. An item that wraps continues on an indented line
|
||||||
|
and is still one item. A `- [ ]` inside a ``` code fence is an example of the
|
||||||
|
markup, not state. Tick them with `issue_ac.py`, which reads the whole body
|
||||||
|
on exactly these rules and rewrites one character; progress (`3/7`) is
|
||||||
|
counted off the body and is never a metadata field.
|
||||||
|
- 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 know
|
||||||
|
nothing about the container.
|
||||||
|
|
||||||
|
**The container depends on its children, never the reverse.** Every child id
|
||||||
|
goes in the container's own `depends:` and, as prose, in its `## Issues`
|
||||||
|
section; a child's `depends:` is for that child's real dependencies and must
|
||||||
|
not point back at the container. Keep implementation detail in the children;
|
||||||
|
the feature body stays at business level.
|
||||||
|
|
||||||
|
That direction is not a convention picked at random. "The container is closed
|
||||||
|
when its children are closed" *is* a dependency relation. "This child belongs
|
||||||
|
to that feature" is a membership relation, and membership has no place in a
|
||||||
|
dependency graph. Pointed the other way the two rules contradict each other:
|
||||||
|
the moment the container listed a child that already depended on it,
|
||||||
|
`issue_check.py` would report `ERROR cycle`. With the edge going down, the
|
||||||
|
graph reads as nesting — `issue_tree.py` draws the container as the root with
|
||||||
|
its children beneath it — and the check is green.
|
||||||
|
|
||||||
|
So the container's metadata block carries the children:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
depends: [wire-sqlc-appclick, add-pool-cfg]
|
||||||
|
```
|
||||||
|
|
||||||
|
and its body repeats them for a human:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
Бизнес-ценность одним-двумя предложениями.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
Ссылка или `none`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Какую проблему пользователя/системы это решает.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
- [ ] wire-sqlc-appclick — краткое описание части
|
||||||
|
- [ ] add-pool-cfg — краткое описание части
|
||||||
|
|
||||||
|
## 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,713 @@
|
|||||||
|
#!/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 collections
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# where the store lives
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# `<repo root>/tmp/issues`, absolute, resolved once at import.
|
||||||
|
#
|
||||||
|
# It used to be the relative `tmp/issues`, which made "the store" whatever
|
||||||
|
# directory the shell happened to be standing in. One `cd` — and a `cd` outlives
|
||||||
|
# the command that ran it — was enough for readers to report an empty store on a
|
||||||
|
# full one and for writers to quietly build a second store beside the first.
|
||||||
|
#
|
||||||
|
# The anchor is THIS FILE, not the working directory. A script's own location is
|
||||||
|
# a fact about the installation; cwd is a fact about the last `cd`. Walking up
|
||||||
|
# from __file__ therefore hands every script in both layers the same answer no
|
||||||
|
# matter where it is invoked from — including from inside tmp/issues itself.
|
||||||
|
#
|
||||||
|
# An explicit --out still wins over all of this, and is used exactly as typed: a
|
||||||
|
# relative --out stays relative to cwd, because that is what the operator asked
|
||||||
|
# for. There is no environment override; the store is where the repo is.
|
||||||
|
|
||||||
|
STORE_PARTS = ("tmp", "issues")
|
||||||
|
|
||||||
|
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||||
|
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||||
|
# git; the agents-sync hook only ever puts one at a repository root.
|
||||||
|
REPO_MARKERS = (".git", "AGENTS.md")
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root(start):
|
||||||
|
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None.
|
||||||
|
|
||||||
|
Markers, not a fixed number of `..` hops: how deep this file sits below the
|
||||||
|
root is an implementation detail of the repo layout, and the layout is not
|
||||||
|
a promise."""
|
||||||
|
d = os.path.abspath(start)
|
||||||
|
while True:
|
||||||
|
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
|
||||||
|
return d
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
return None
|
||||||
|
d = parent
|
||||||
|
|
||||||
|
|
||||||
|
def store_root(start=None):
|
||||||
|
"""Absolute path of the issue store.
|
||||||
|
|
||||||
|
`start` overrides the anchor and exists so the resolution can be exercised
|
||||||
|
against a scratch tree. When these scripts are not inside a repository at
|
||||||
|
all, cwd gets a turn; failing that the historical cwd-relative location
|
||||||
|
stands, made absolute so an error message can name the directory it really
|
||||||
|
looked in."""
|
||||||
|
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||||
|
root = repo_root(anchor)
|
||||||
|
if root:
|
||||||
|
return os.path.join(root, *STORE_PARTS)
|
||||||
|
return os.path.abspath(os.path.join(*STORE_PARTS))
|
||||||
|
|
||||||
|
|
||||||
|
ISSUE_ROOT = store_root()
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
ISSUES_SECTION = "## Issues"
|
||||||
|
# Both sections name what an issue depends on, so both are edge sources and
|
||||||
|
# both point the same way. In a `type/feature` that reads container -> child:
|
||||||
|
# "the container is closed when its children are closed" IS a dependency.
|
||||||
|
# "a child belongs to a feature" is membership, and membership has no place in
|
||||||
|
# a dependency graph — which is why a child never names its container back.
|
||||||
|
DEP_SECTIONS = (DEPENDS_SECTION, ISSUES_SECTION)
|
||||||
|
# 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_SECTION],
|
||||||
|
"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 complete state, not a pending one — and the state in which this file
|
||||||
|
is the only copy of the work. An issue whose `origin` names somewhere
|
||||||
|
else can be fetched from there again; this one cannot."""
|
||||||
|
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_ref_sections(body):
|
||||||
|
"""[(section, ref)] for every reference under one of DEP_SECTIONS — never
|
||||||
|
from prose, or a graph walk would drag in half the backlog. Refs are
|
||||||
|
whatever was written there (slugs, and `#N` on issues that came from a
|
||||||
|
tracker), deduplicated on first sight.
|
||||||
|
|
||||||
|
The section is carried out with the ref so a caller can name the one the
|
||||||
|
reader actually has in front of them: a container's children come from
|
||||||
|
`## Issues`, and pointing at `## Depends on` would name a section that is
|
||||||
|
not in the file."""
|
||||||
|
out, seen, section = [], set(), ""
|
||||||
|
for line in (body or "").splitlines():
|
||||||
|
if line.startswith("## "):
|
||||||
|
head = line.strip()
|
||||||
|
section = head if head in DEP_SECTIONS else ""
|
||||||
|
continue
|
||||||
|
if not section:
|
||||||
|
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 seen:
|
||||||
|
seen.add(ref)
|
||||||
|
out.append((section, ref))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def body_dep_refs(body):
|
||||||
|
"""Just the refs, in order of first appearance."""
|
||||||
|
return [ref for _, ref in body_dep_ref_sections(body)]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# checkboxes
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# A checkbox is the one part of a body that is *state* and not prose, so the
|
||||||
|
# format gives it markup of its own (references/format.md:163-164). It is item
|
||||||
|
# markup, not a property of one section: `## Acceptance criteria` is the usual
|
||||||
|
# home, but a type/feature keeps its children as checkboxes under `## Issues`
|
||||||
|
# (format.md:275-277). The scan is therefore over the whole text and the
|
||||||
|
# heading is only recorded, never required.
|
||||||
|
CHECKBOX_RE = re.compile(
|
||||||
|
r'^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+'
|
||||||
|
r'\[(?P<box>[ xX])\](?=[ \t]|$)(?P<text>.*)$')
|
||||||
|
# Any list item — a sibling ends the item above it, checkbox or not.
|
||||||
|
LIST_ITEM_RE = re.compile(r'^[ \t]*([-*+]|\d+[.)])([ \t]|$)')
|
||||||
|
FENCE_RE = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})')
|
||||||
|
|
||||||
|
Checkbox = collections.namedtuple(
|
||||||
|
"Checkbox", "index line end_line checked text section")
|
||||||
|
|
||||||
|
|
||||||
|
def checkboxes(text):
|
||||||
|
"""Every checkbox item in `text`, in document order.
|
||||||
|
|
||||||
|
A pure function of the string it is given — no I/O, no store, no tracker.
|
||||||
|
Pass an issue body (`Issue.body`) to get body-relative line numbers, or a
|
||||||
|
whole file to get file-relative ones; nothing else changes.
|
||||||
|
|
||||||
|
Returns a list of `Checkbox` namedtuples:
|
||||||
|
|
||||||
|
index 1-based position in this list — what a user types to pick it
|
||||||
|
line 1-based line of the `- [ ]` marker, in the text given
|
||||||
|
end_line 1-based last line of the item, continuation lines included
|
||||||
|
checked True for `[x]` / `[X]`, False for `[ ]`
|
||||||
|
text the item's text; continuation lines joined with one space
|
||||||
|
section nearest preceding `## ` heading, "" above the first one
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Only a line matching CHECKBOX_RE opens an item. A wrapped ("continuation")
|
||||||
|
line is part of the item above it, never an item of its own; the item
|
||||||
|
runs to the next blank line, heading, code fence, or list marker.
|
||||||
|
- Fenced code blocks are skipped whole: `- [ ]` inside a ``` fence is an
|
||||||
|
example of the markup, not a box anybody may tick.
|
||||||
|
- `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
|
||||||
|
lists are seen too.
|
||||||
|
"""
|
||||||
|
lines = (text or "").splitlines()
|
||||||
|
items, section, fence = [], "", ""
|
||||||
|
for n, line in enumerate(lines, 1):
|
||||||
|
m = FENCE_RE.match(line)
|
||||||
|
if m:
|
||||||
|
tok = m.group(1)
|
||||||
|
if not fence:
|
||||||
|
fence = tok
|
||||||
|
elif tok[0] == fence[0] and len(tok) >= len(fence):
|
||||||
|
fence = ""
|
||||||
|
continue
|
||||||
|
if fence:
|
||||||
|
continue
|
||||||
|
if line.startswith("## "):
|
||||||
|
section = line.strip()
|
||||||
|
continue
|
||||||
|
if line.startswith("# "):
|
||||||
|
section = ""
|
||||||
|
continue
|
||||||
|
m = CHECKBOX_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
end, parts = n, [m.group("text").strip()]
|
||||||
|
for k in range(n, len(lines)): # lines[k] is line number k + 1
|
||||||
|
nxt = lines[k]
|
||||||
|
if (not nxt.strip() or nxt.startswith("#")
|
||||||
|
or FENCE_RE.match(nxt) or LIST_ITEM_RE.match(nxt)):
|
||||||
|
break
|
||||||
|
end = k + 1
|
||||||
|
parts.append(nxt.strip())
|
||||||
|
items.append(Checkbox(len(items) + 1, n, end,
|
||||||
|
m.group("box") != " ",
|
||||||
|
" ".join(p for p in parts if p), section))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def set_checkbox(text, item, checked=True):
|
||||||
|
"""Return `text` with one checkbox set to `checked`.
|
||||||
|
|
||||||
|
Pure, and deliberately surgical: exactly one character of the input
|
||||||
|
changes — the one between the brackets. Everything else, including
|
||||||
|
trailing whitespace and the item's own wording, comes back byte for byte.
|
||||||
|
That is the whole point of the function: ticking a box must not produce a
|
||||||
|
diff wider than the state that changed.
|
||||||
|
|
||||||
|
`item` is a `Checkbox` from `checkboxes(text)` — the same text, or the
|
||||||
|
line number will point at the wrong line — or a 1-based line number.
|
||||||
|
Already in the requested state is a no-op: `text` is returned unchanged,
|
||||||
|
and an existing `[X]` keeps its capital.
|
||||||
|
"""
|
||||||
|
line_no = item.line if isinstance(item, Checkbox) else int(item)
|
||||||
|
off = 0
|
||||||
|
for n, raw in enumerate(text.splitlines(True), 1):
|
||||||
|
if n == line_no:
|
||||||
|
m = CHECKBOX_RE.match(raw.rstrip("\r\n"))
|
||||||
|
if not m:
|
||||||
|
raise ValueError("line %d is not a checkbox item" % line_no)
|
||||||
|
if (m.group("box") != " ") == bool(checked):
|
||||||
|
return text
|
||||||
|
box = off + m.start("box")
|
||||||
|
return text[:box] + ("x" if checked else " ") + text[box + 1:]
|
||||||
|
off += len(raw)
|
||||||
|
raise ValueError("line %d is past the end of the text" % line_no)
|
||||||
|
|
||||||
|
|
||||||
|
def checkbox_progress(text):
|
||||||
|
"""(done, total) over every checkbox in `text`; (0, 0) when it has none.
|
||||||
|
|
||||||
|
Computed on the fly, on purpose. Progress is not a metadata field: it is
|
||||||
|
the body read back, and the body is the only place the state lives."""
|
||||||
|
items = checkboxes(text)
|
||||||
|
return sum(1 for c in items if c.checked), len(items)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 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. Name the section
|
||||||
|
# the reference actually came from — for a container that is `## Issues`.
|
||||||
|
listed = set(issue.depends)
|
||||||
|
for section, ref in body_dep_ref_sections(issue.body):
|
||||||
|
if not ref.startswith("#") and ref not in listed:
|
||||||
|
warn.append("%s mentions %r but `depends:` does not list it"
|
||||||
|
% (section, ref))
|
||||||
|
|
||||||
|
# An unticked checkbox is never a finding — neither an error nor a
|
||||||
|
# warning. `- [ ]` is work not done yet, which is the normal state of a
|
||||||
|
# perfectly well-formed issue. Reading that state is issue_ac.py's job.
|
||||||
|
|
||||||
|
return err, warn
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# store
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class StoreMissing(Exception):
|
||||||
|
"""The store directory is not there.
|
||||||
|
|
||||||
|
Deliberately a different answer from "the store is empty". One is a path
|
||||||
|
that does not exist, the other is a repository with no issues filed yet, and
|
||||||
|
conflating the two is exactly what made a missed directory look like an
|
||||||
|
empty backlog."""
|
||||||
|
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
Exception.__init__(self, "store %s does not exist" % root)
|
||||||
|
|
||||||
|
|
||||||
|
def store_exists(root):
|
||||||
|
return os.path.isdir(root)
|
||||||
|
|
||||||
|
|
||||||
|
def require_store(root):
|
||||||
|
"""Assert the store is there before reading or writing it."""
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
raise StoreMissing(root)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def create_store(root):
|
||||||
|
"""Create the store; True when it actually made the directory.
|
||||||
|
|
||||||
|
Only the commands that legitimately bootstrap a store call this — issue_new
|
||||||
|
and pull — and both announce it. Nothing creates a store as a side effect of
|
||||||
|
a write any more: a missing directory is something to report, not something
|
||||||
|
to conjure."""
|
||||||
|
if os.path.isdir(root):
|
||||||
|
return False
|
||||||
|
os.makedirs(root)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def store_error(root):
|
||||||
|
"""Why `root` cannot be read as a store, or None when it holds issues.
|
||||||
|
|
||||||
|
The two messages are distinct on purpose — see StoreMissing."""
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return ("store %s does not exist — nothing was created; pass --out to "
|
||||||
|
"point elsewhere" % root)
|
||||||
|
if not all_ids(root):
|
||||||
|
return "store %s exists but is empty" % root
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def path_of(root, id):
|
||||||
|
return os.path.join(root, "%s.md" % id)
|
||||||
|
|
||||||
|
|
||||||
|
def all_ids(root):
|
||||||
|
"""Every issue in the store, by slug.
|
||||||
|
|
||||||
|
An issue file is named by its slug and a slug has no dot in it (SLUG_OK),
|
||||||
|
so `<id>.comments.md` — the thread the sync layer parks beside an issue —
|
||||||
|
is not one, and neither is anything else that grew a second extension.
|
||||||
|
Without that rule `wire-sqlc.comments` reads as an issue called
|
||||||
|
`wire-sqlc.comments`, and a bare `push.py` tries to file the comment thread
|
||||||
|
as a unit of work."""
|
||||||
|
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-"))
|
||||||
|
and "." not in f[:-3])
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
require_store(root)
|
||||||
|
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,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
issue_ac.py — list and tick the checkboxes in an issue's body. Offline.
|
||||||
|
|
||||||
|
issue_ac.py wire-sqlc-appclick numbered list with state
|
||||||
|
issue_ac.py wire-sqlc-appclick --check 3 by number
|
||||||
|
issue_ac.py wire-sqlc-appclick --check регресс by substring
|
||||||
|
issue_ac.py wire-sqlc-appclick --uncheck 3
|
||||||
|
|
||||||
|
A checkbox is the one part of a body that is *state* and not prose. Everything
|
||||||
|
else is written once; boxes get ticked as the work goes, and until now the only
|
||||||
|
ways to tick one were a human with an editor or a model rewriting the whole
|
||||||
|
body — the second worse than the first, because the rewrite re-flows the text
|
||||||
|
and the issue's diff swells around a change of one character. This changes that
|
||||||
|
one character and nothing else.
|
||||||
|
|
||||||
|
Named after `## Acceptance criteria`, where most boxes live, but every checkbox
|
||||||
|
in the body is listed and tickable: a type/feature keeps its children under
|
||||||
|
`## Issues`, and binding this to one heading would silently lose half of them.
|
||||||
|
|
||||||
|
A substring picks an item only when it picks exactly one. Two matches is an
|
||||||
|
error listing both — a coin flip would tick the wrong box and look like it
|
||||||
|
worked.
|
||||||
|
|
||||||
|
Delivering the changed body to a tracker is not part of this: that is
|
||||||
|
`push.py --update` in /tea:sync.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import issue # noqa: E402
|
||||||
|
import issue_index # noqa: E402
|
||||||
|
|
||||||
|
NUMBER = re.compile(r'^\d+$')
|
||||||
|
|
||||||
|
|
||||||
|
def box(c):
|
||||||
|
return "[x]" if c.checked else "[ ]"
|
||||||
|
|
||||||
|
|
||||||
|
def listing(items):
|
||||||
|
"""The numbered list, grouped by the heading each item sits under."""
|
||||||
|
out, section = [], None
|
||||||
|
for c in items:
|
||||||
|
if c.section != section:
|
||||||
|
section = c.section
|
||||||
|
out.append("")
|
||||||
|
out.append(section or "(above the first heading)")
|
||||||
|
out.append(" %2d %s %s" % (c.index, box(c), c.text))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def select(items, needle):
|
||||||
|
"""Resolve a --check/--uncheck argument to exactly one item, or exit."""
|
||||||
|
needle = (needle or "").strip()
|
||||||
|
if not needle:
|
||||||
|
sys.exit("issue_ac.py: empty selector — give an item number or a substring")
|
||||||
|
if NUMBER.match(needle):
|
||||||
|
n = int(needle)
|
||||||
|
if not 1 <= n <= len(items):
|
||||||
|
sys.exit("issue_ac.py: no item %d — the issue has %d" % (n, len(items)))
|
||||||
|
return items[n - 1]
|
||||||
|
hits = [c for c in items if needle.lower() in c.text.lower()]
|
||||||
|
if not hits:
|
||||||
|
sys.exit("issue_ac.py: nothing matches %r" % needle)
|
||||||
|
if len(hits) > 1:
|
||||||
|
sys.exit("\n".join(
|
||||||
|
["issue_ac.py: %r matches %d items — narrow it down, or use a number:"
|
||||||
|
% (needle, len(hits))]
|
||||||
|
+ [" %2d %s %s" % (c.index, box(c), c.text) for c in hits]))
|
||||||
|
return hits[0]
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="List and tick an issue's checkboxes (offline)")
|
||||||
|
ap.add_argument("id", help="issue id (the slug, without .md)")
|
||||||
|
g = ap.add_mutually_exclusive_group()
|
||||||
|
g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring")
|
||||||
|
g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring")
|
||||||
|
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
path = issue.path_of(args.out, args.id)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out))
|
||||||
|
# newline="": no translation in either direction. Byte-for-byte means the
|
||||||
|
# line endings too — reading a CRLF file in text mode and writing it back
|
||||||
|
# would rewrite every line while claiming to have changed one character.
|
||||||
|
with open(path, newline="") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
# The whole file, not just the body: line numbers then point at the file,
|
||||||
|
# and the metadata block is rewritten by nobody. Round-tripping through
|
||||||
|
# Issue.to_text() would re-render metadata and re-strip the body, which is
|
||||||
|
# exactly the byte-level churn this script exists to avoid.
|
||||||
|
items = issue.checkboxes(text)
|
||||||
|
needle = args.check if args.check is not None else args.uncheck
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
if needle is not None:
|
||||||
|
sys.exit("issue_ac.py: %s has no checkboxes" % args.id)
|
||||||
|
print("%s — no checkboxes" % args.id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if needle is None:
|
||||||
|
done = sum(1 for c in items if c.checked)
|
||||||
|
print("%s — %d/%d %s" % (args.id, done, len(items), path))
|
||||||
|
print("\n".join(listing(items)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
checked = args.check is not None
|
||||||
|
item = select(items, needle)
|
||||||
|
new = issue.set_checkbox(text, item, checked)
|
||||||
|
verb = "checked" if checked else "unchecked"
|
||||||
|
if new == text:
|
||||||
|
print("unchanged %2d %s %s" % (item.index, box(item), item.text))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with open(path, "w", newline="") as f:
|
||||||
|
f.write(new)
|
||||||
|
issue_index.build(args.out)
|
||||||
|
|
||||||
|
done, total = issue.checkbox_progress(new)
|
||||||
|
print("%s %2d %s %s" % (verb, item.index, "[x]" if checked else "[ ]", item.text))
|
||||||
|
print("%s — %d/%d %s:%d" % (args.id, done, total, path, item.line))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/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: <repo>/tmp/issues)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
problem = issue.store_error(args.out)
|
||||||
|
if problem:
|
||||||
|
sys.exit("issue_check.py: %s" % problem)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
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,116 @@
|
|||||||
|
#!/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.
|
||||||
|
|
||||||
|
The store is <repo root>/tmp/issues unless --out says otherwise; an existing
|
||||||
|
store with nothing in it gets an "_empty_" table, a store that is not there is
|
||||||
|
an error rather than a directory to create.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
issue_index.py [--out DIR]
|
||||||
|
"""
|
||||||
|
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 progress(body):
|
||||||
|
"""`3/7` for a body with checkboxes, "" for one without.
|
||||||
|
|
||||||
|
Counted from the body every time the index is built and stored nowhere —
|
||||||
|
the boxes are the state, and a second copy of it in a metadata field would
|
||||||
|
be wrong by the next edit."""
|
||||||
|
done, total = issue.checkbox_progress(body)
|
||||||
|
return "%d/%d" % (done, total) if total else ""
|
||||||
|
|
||||||
|
|
||||||
|
def build(root):
|
||||||
|
# An index of a store that is not there is not an empty index, it is a bad
|
||||||
|
# path. Raising beats writing INDEX.md into a directory nobody asked for.
|
||||||
|
issue.require_store(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),
|
||||||
|
"progress": progress(iss.body),
|
||||||
|
"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. `progress` counts the body's checkboxes, ticked over "
|
||||||
|
"total, and is blank for an issue that has none — read off the "
|
||||||
|
"body at build time, stored nowhere. Rebuild with `issue_index.py`; "
|
||||||
|
"tick a box with `issue_ac.py`.", ""]
|
||||||
|
if rows:
|
||||||
|
out += ["| id | state | progress | type | labels | title | milestone | depends | origin |",
|
||||||
|
"|---|---|---|---|---|---|---|---|---|"]
|
||||||
|
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % (
|
||||||
|
r["id"], r["id"], r["state"], r["progress"], 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")
|
||||||
|
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: <repo>/tmp/issues)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
# An existing store with nothing in it is a legitimate thing to index — it
|
||||||
|
# gets an "_empty_" table. A store that is not there is not.
|
||||||
|
try:
|
||||||
|
path, n = build(args.out)
|
||||||
|
except issue.StoreMissing as e:
|
||||||
|
sys.exit("issue_index.py: %s — nothing was created; create an issue with "
|
||||||
|
"issue_new.py, or pass --out" % e)
|
||||||
|
print("%s — %d issue(s)" % (path, n))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
#!/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 complete state and pushing
|
||||||
|
it to Gitea later (see /tea:sync) is optional.
|
||||||
|
|
||||||
|
While it says `local`, this file is the ONLY copy of the work — the store, not
|
||||||
|
a cache of anything. That is what a push changes: it hands the issue to the
|
||||||
|
tracker and removes the file.
|
||||||
|
|
||||||
|
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: <repo>/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)
|
||||||
|
|
||||||
|
# The first issue in a fresh checkout has to create the store, but it says
|
||||||
|
# so — and it says where, because the path is absolute.
|
||||||
|
if issue.create_store(args.out):
|
||||||
|
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
|
||||||
|
|
||||||
|
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,99 @@
|
|||||||
|
#!/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: <repo>/tmp/issues)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
problem = issue.store_error(args.out)
|
||||||
|
if problem:
|
||||||
|
sys.exit("issue_tree.py: %s" % problem)
|
||||||
|
|
||||||
|
issues = issue.load_all(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,88 @@
|
|||||||
|
---
|
||||||
|
name: page
|
||||||
|
description: Organize a discussion's artifacts into a named, ordered tree of wiki pages — import a directory of markdown, give every file a title, build the index, see what a space holds. Entirely offline; pages are local markdown files and need no wiki. Load when the user asks to turn notes/artifacts into wiki pages, organize or re-title a page tree, or rebuild a table of contents. For fetching from or publishing to a Gitea wiki, load /tea:wiki instead.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /tea:page — discussion artifacts as a page tree
|
||||||
|
|
||||||
|
A discussion produces artifacts wherever the discussion happened — a directory
|
||||||
|
of markdown with numbered files and subdirectories. This skill turns that into
|
||||||
|
a **space**: a named, ordered tree of pages with a manifest, living under
|
||||||
|
`tmp/wiki/`.
|
||||||
|
|
||||||
|
**Nothing here touches the network.** No `tea`, no Gitea, no login. A space that
|
||||||
|
never leaves this machine is a finished thing, not a draft waiting for an
|
||||||
|
upload. Publishing is a separate, optional layer — `/tea:wiki`.
|
||||||
|
|
||||||
|
Read [`references/pages.md`](references/pages.md) before importing or
|
||||||
|
re-titling. It is the single source of truth for titles, ordering, paths, the
|
||||||
|
manifest, and the index.
|
||||||
|
|
||||||
|
## Identity: the title
|
||||||
|
|
||||||
|
`Simple Chains/Ideas/Chain core`. The `/` is the only hierarchy there is — the
|
||||||
|
wiki this feeds is flat and has no directories. The local path is derived from
|
||||||
|
the title (`Simple-Chains/Ideas/Chain-core.md`); the reverse never happens.
|
||||||
|
|
||||||
|
A title is chosen **once**, at import or at pull, and then it is a fact in the
|
||||||
|
manifest. Editing a heading does not rename a page. Renaming is `--retitle`,
|
||||||
|
and on a published page it orphans the old one.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
All offline, all in `<skill-base-dir>/scripts/`.
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `page_import.py --from DIR [--space S] [--prefix T]` | copy a directory of markdown into a space, titling every file |
|
||||||
|
| `page_index.py [--space S] [--prefix T]` | write the table-of-contents page — the navigation the flat wiki cannot provide |
|
||||||
|
| `page_ls.py [--space S] [--prefix T]` | the tree, the titles, and one sync-state tag per page |
|
||||||
|
| `page.py` | the domain module the others import — not a command |
|
||||||
|
|
||||||
|
```
|
||||||
|
tmp/wiki/claude-skills/tea/ a space
|
||||||
|
.pages.json the manifest — titles, order, sync bookkeeping
|
||||||
|
Simple-Chains.md the index page
|
||||||
|
Simple-Chains/Ideas.md title: Simple Chains/Ideas
|
||||||
|
Simple-Chains/Ideas/Chain-core.md title: Simple Chains/Ideas/Chain core
|
||||||
|
```
|
||||||
|
|
||||||
|
## The usual run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/page_import.py \
|
||||||
|
--from ~/proj/tmp/simple-chains \
|
||||||
|
--space claude-skills/tea --prefix "Simple Chains" --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
`--dry-run` first, always: it prints every path and the title it would get, and
|
||||||
|
that listing is the only chance to catch a heading that titles a page badly
|
||||||
|
before the name becomes a decision. Drop the flag to write.
|
||||||
|
|
||||||
|
Then the index, then look at it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/page_index.py --space claude-skills/tea --prefix "Simple Chains"
|
||||||
|
python3 scripts/page_ls.py --space claude-skills/tea --prefix "Simple Chains"
|
||||||
|
```
|
||||||
|
|
||||||
|
`page_ls.py` tags each page `local` (never published), `synced` (published and
|
||||||
|
unchanged), or `ahead` (edited since it was published). `local` is a complete
|
||||||
|
state.
|
||||||
|
|
||||||
|
## Where the cache is
|
||||||
|
|
||||||
|
`<repo root>/tmp/wiki` — **not** `tmp/wiki` relative to wherever you are
|
||||||
|
standing. The scripts resolve it by walking up from their own file to the
|
||||||
|
nearest `.git` or `AGENTS.md`, so they all see one cache no matter which
|
||||||
|
directory they are run from.
|
||||||
|
|
||||||
|
`--out` overrides that and is taken **literally**: an absolute path is used as
|
||||||
|
given, a relative one stays relative to the current directory.
|
||||||
|
|
||||||
|
## Re-importing is the normal refresh
|
||||||
|
|
||||||
|
The discussion continues, the artifacts change, run the same import again.
|
||||||
|
Bodies are replaced, titles are kept, `sub_url` and the rest of the wiki
|
||||||
|
bookkeeping survive — so the next push updates the pages that already exist
|
||||||
|
instead of publishing a second copy of each.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# The page-tree format
|
||||||
|
|
||||||
|
Canonical. Everything about how a discussion's artifacts become named, ordered,
|
||||||
|
navigable pages lives here. The scripts implement this document; when they
|
||||||
|
disagree, this document is right.
|
||||||
|
|
||||||
|
## The one fact that shapes everything: the wiki is flat
|
||||||
|
|
||||||
|
Gitea's wiki has no directories. It has a list of pages, each stored as one
|
||||||
|
file whose name Gitea escapes from the title:
|
||||||
|
|
||||||
|
| title | file Gitea writes | `sub_url` |
|
||||||
|
|---|---|---|
|
||||||
|
| `Abstract Issue` | `Abstract-Issue.md` | `Abstract-Issue` |
|
||||||
|
| `zz-probe/child` | `zz-probe%2Fchild.-.md` | `zz-probe%2Fchild.-` |
|
||||||
|
| `Simple Chains/Parked/Chain decisions — DC` | `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC.md` | same, minus `.md` |
|
||||||
|
|
||||||
|
Three rules are visible in that table, and all three are Gitea's to change:
|
||||||
|
space becomes `-`; `/` becomes `%2F`; a **literal** `-` in the title forces a
|
||||||
|
trailing `.-` marker so it stays distinguishable from a space.
|
||||||
|
|
||||||
|
Two consequences run through the whole design.
|
||||||
|
|
||||||
|
**Hierarchy lives in the title and nowhere else.** `/` inside a title is the
|
||||||
|
only nesting there is. A real subdirectory committed into the wiki's git
|
||||||
|
repository — `folder/page.md` — is invisible to the API and to the web UI. It
|
||||||
|
is a ghost file. Never create one.
|
||||||
|
|
||||||
|
**`sub_url` is identity and is never constructed.** It is read back from
|
||||||
|
whatever the API returned and stored in the manifest. A hand-built one that is
|
||||||
|
almost right does not fail loudly; it creates a second page and abandons the
|
||||||
|
first.
|
||||||
|
|
||||||
|
## The space
|
||||||
|
|
||||||
|
```
|
||||||
|
tmp/wiki/claude-skills/tea/ a SPACE
|
||||||
|
.pages.json the manifest
|
||||||
|
Simple-Chains.md title: Simple Chains (the index)
|
||||||
|
Simple-Chains/
|
||||||
|
Ideas.md title: Simple Chains/Ideas
|
||||||
|
Ideas/
|
||||||
|
Chain-core.md title: Simple Chains/Ideas/Chain core
|
||||||
|
```
|
||||||
|
|
||||||
|
A space is a directory holding a page tree and one manifest. Its name is
|
||||||
|
normally the `owner/repo` it syncs with, and to the domain layer that is an
|
||||||
|
opaque relative path — `--space docs` and `--space a/b/c` are equally valid.
|
||||||
|
|
||||||
|
The path is `<repo root>/tmp/wiki`, resolved from `page.py`'s own location and
|
||||||
|
not from the working directory. `--out` overrides it and is used exactly as
|
||||||
|
typed. Nothing creates a space as a side effect of a write: the scripts say so
|
||||||
|
on stderr when they make one.
|
||||||
|
|
||||||
|
## The manifest
|
||||||
|
|
||||||
|
`.pages.json`, one entry per page, keyed by the file's path inside the space.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"space": "claude-skills/tea",
|
||||||
|
"pages": {
|
||||||
|
"Simple-Chains/Ideas/Chain-core.md": {
|
||||||
|
"title": "Simple Chains/Ideas/Chain core",
|
||||||
|
"order": 2,
|
||||||
|
"pushed": "9a1ab2e3bfd45f7c7ba323d9d8cd59642d6f0540",
|
||||||
|
"remote-updated": "2026-08-10T11:15:39Z",
|
||||||
|
"sha": "fc8ec1779d910850f49bfef60dd5a0e737bbdc8a",
|
||||||
|
"sub_url": "Simple-Chains%2FIdeas%2FChain-core",
|
||||||
|
"synced": "2026-08-10T11:15:39Z",
|
||||||
|
"url": "https://git.noodles.cam/…/wiki/Simple-Chains%2FIdeas%2FChain-core"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| key | owner | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | domain | the page's name; `/` is hierarchy |
|
||||||
|
| `order` | domain | sort key from a `NN-` file-name prefix; absent when there was none |
|
||||||
|
| `sub_url` | wiki | Gitea's address for the page — **the identity** |
|
||||||
|
| `pushed` | wiki | sha1 of the bytes last published; the whole of change detection |
|
||||||
|
| `sha` | wiki | the wiki commit the local copy came from |
|
||||||
|
| `synced` | wiki | when this copy was fetched or pushed |
|
||||||
|
| `url` | wiki | browser link |
|
||||||
|
| `remote-updated` | wiki | the page's last commit date in the wiki |
|
||||||
|
|
||||||
|
The domain layer writes `title` and `order`, carries everything else through
|
||||||
|
load and save verbatim, and never reads it. A page with no `sub_url` has never
|
||||||
|
been published — a complete state, not a pending one, exactly as `origin: local`
|
||||||
|
is for an issue.
|
||||||
|
|
||||||
|
## How a source file gets its title
|
||||||
|
|
||||||
|
Applied at import, once. Three rules, in order:
|
||||||
|
|
||||||
|
1. **`order 0`, or a file literally named `index` / `readme`, is the page for
|
||||||
|
the directory it sits in.** `ideas/00-intro.md` becomes `…/Ideas`, not a
|
||||||
|
child of it. Its title comes from the **directory name**, never from its own
|
||||||
|
heading — a child's title has to extend its parent's exactly, and that file
|
||||||
|
opens with "Ideas for chain business requirements", which no child would
|
||||||
|
ever be prefixed by.
|
||||||
|
2. **Otherwise the file's first markdown heading**, sanitized. It is what a
|
||||||
|
human wrote for a human: there is no mechanical route from
|
||||||
|
`03-q-01-do-we-know-the-chain-participant-by-name.md` to
|
||||||
|
`Q-01. Do We Know the Chain Participant by Name`.
|
||||||
|
3. **No heading: the file name**, made readable — `NN-` stripped, `-` and `_`
|
||||||
|
to spaces, first letter raised. Only the first letter: title-casing would
|
||||||
|
wreck `Q-01`, `sqlc`, and `APNs`.
|
||||||
|
|
||||||
|
Sanitizing a title drops markdown markup (`` ` ``, `*`, `_` — a page list does
|
||||||
|
not render markdown) and turns `/` into `-`, because a slash inside a heading
|
||||||
|
would silently invent a level of hierarchy the author did not ask for.
|
||||||
|
|
||||||
|
### A title is a decision, not a derivation
|
||||||
|
|
||||||
|
Once a page is in the manifest its title stays put. Re-importing replaces the
|
||||||
|
body and leaves the title alone, so editing a heading cannot rename a page —
|
||||||
|
which matters because renaming a **published** page does not move it, it
|
||||||
|
creates a second one and orphans the first. `--retitle` opts into that
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
The reverse direction does not exist. A path is derived from a title; a title
|
||||||
|
is never derived from a path. `02-chain-core` proves why: those dashes are
|
||||||
|
real, and undoing "space became dash" would eat them.
|
||||||
|
|
||||||
|
## Ordering
|
||||||
|
|
||||||
|
A leading `NN-` on a file name is sort order and nothing else — it never
|
||||||
|
reaches the title. `00` is special and means "this is the directory's own
|
||||||
|
page". Pages with an order sort before pages without one: an explicit `NN-` is
|
||||||
|
a decision, its absence is not.
|
||||||
|
|
||||||
|
The wiki cannot hold ordering, so `order` is local-only and survives a pull.
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
A path is one component per title segment, spaces to `-`, with characters a
|
||||||
|
shell has to quote dropped — apostrophes and quotes and commas. `Don't send to
|
||||||
|
this one` keeps its apostrophe in the title and loses it in
|
||||||
|
`Dont-send-to-this-one.md`.
|
||||||
|
|
||||||
|
Two titles can land on one path. That is reported and never resolved
|
||||||
|
automatically: picking a winner is how a discussion loses a document. Rename a
|
||||||
|
source, or rename the page in the wiki, and run it again.
|
||||||
|
|
||||||
|
## The index page
|
||||||
|
|
||||||
|
The wiki will not draw a tree from titles, so an index page is the navigation,
|
||||||
|
not a nicety. `page_index.py` writes one as an ordinary page in the space — it
|
||||||
|
is pushed by the same command as everything else.
|
||||||
|
|
||||||
|
Nesting follows the **titles**, not the manifest's path order; those two
|
||||||
|
disagree, because on disk `Simple-Chains/System.md` sorts before
|
||||||
|
`Simple-Chains/Ideas/Scale.md` while in the hierarchy System is a child and
|
||||||
|
Scale a grandchild. A parent with no page of its own still gets a node, so its
|
||||||
|
children are not hidden.
|
||||||
|
|
||||||
|
Links: a published page is linked by its `sub_url`, the only address Gitea
|
||||||
|
guarantees. A page that has never been pushed gets Gitea's `[[Title|label]]`
|
||||||
|
wiki-link syntax, which resolves the escaping on the server at render time.
|
||||||
|
Rebuilding the index after a push upgrades those links to exact ones — so the
|
||||||
|
order is **push, rebuild the index, push again**.
|
||||||
|
|
||||||
|
## What the sync does not do
|
||||||
|
|
||||||
|
- **No merge.** A pull overwrites the local body. `synced` tells you how old
|
||||||
|
your copy is; re-pull when it matters.
|
||||||
|
- **No drift tracking.** `pushed` answers one question — is the local file
|
||||||
|
different from what was published — and answers it with a hash.
|
||||||
|
- **No deletes.** Pushing is additive. A page removed locally stays in the
|
||||||
|
wiki; removing a published page is an explicit act, done in the web UI or
|
||||||
|
with a `DELETE` through `/tea:use`.
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""
|
||||||
|
page.py — what a PAGE TREE is. The domain layer for wiki artifacts.
|
||||||
|
|
||||||
|
Not a command; the module the other page scripts build on. It knows how a
|
||||||
|
directory of markdown becomes a named, ordered tree of pages, and it knows
|
||||||
|
NOTHING about any wiki: no Gitea, no `tea`, no logins, no HTTP, no `sub_url`.
|
||||||
|
The layering rule is mechanically checkable — every import in this directory is
|
||||||
|
stdlib, and `subprocess` is not among them:
|
||||||
|
|
||||||
|
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
|
||||||
|
|
||||||
|
Delete skills/wiki/ entirely and this layer keeps working: a discussion's
|
||||||
|
artifacts organized into a tree on this machine are a finished thing, not a
|
||||||
|
draft waiting for an upload.
|
||||||
|
|
||||||
|
tmp/wiki/claude-skills/tea/ <- a SPACE
|
||||||
|
.pages.json <- the manifest
|
||||||
|
Simple-Chains/
|
||||||
|
Ideas.md title: Simple Chains/Ideas
|
||||||
|
Ideas/
|
||||||
|
Chain-core.md title: Simple Chains/Ideas/Chain core
|
||||||
|
|
||||||
|
A space is a directory holding a page tree and one manifest. The space's name
|
||||||
|
("claude-skills/tea") is an opaque relative path to this module — it happens to
|
||||||
|
be an owner/repo pair, and this layer never learns that.
|
||||||
|
|
||||||
|
Why a manifest at all
|
||||||
|
---------------------
|
||||||
|
Because the wiki's own page identity is not derivable from a file path, and
|
||||||
|
guessing at it is how you get duplicate pages. The manifest is the record of
|
||||||
|
what each local file IS, written once at import or pull and never re-derived.
|
||||||
|
|
||||||
|
Domain keys in a manifest entry are `title` and `order`. Everything else —
|
||||||
|
`sub_url`, `sha`, `synced`, `pushed` — is written by the wiki layer, carried
|
||||||
|
through load/save verbatim, and never read here. That passthrough is what lets
|
||||||
|
one manifest describe both a local-only tree and a published one without the
|
||||||
|
domain learning a second vocabulary.
|
||||||
|
|
||||||
|
Titles
|
||||||
|
------
|
||||||
|
The title is the identity that matters, and `/` inside it is the ONLY
|
||||||
|
hierarchy there is — the wiki this feeds has no directories. A local path is
|
||||||
|
derived from the title, never the reverse:
|
||||||
|
|
||||||
|
title "Simple Chains/Ideas/Chain core"
|
||||||
|
path "Simple-Chains/Ideas/Chain-core.md"
|
||||||
|
|
||||||
|
That direction is deliberate. Deriving a title back from a path would have to
|
||||||
|
undo `-`-for-space, and `02-chain-core` proves it cannot: the dashes there are
|
||||||
|
real. So a title is chosen ONCE, at import or at pull, and then it is a fact in
|
||||||
|
the manifest. Renaming is an explicit act, not a side effect of editing a
|
||||||
|
heading.
|
||||||
|
|
||||||
|
Ordering
|
||||||
|
--------
|
||||||
|
A leading `NN-` on a file name is sort order and nothing else — it never
|
||||||
|
reaches the title. `order 0` is special: it is the directory's own page, so
|
||||||
|
`ideas/00-intro.md` becomes the page "…/Ideas" rather than a child of it.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# where the cache lives
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# `<repo root>/tmp/wiki`, absolute, resolved once at import — the same anchoring
|
||||||
|
# rule the issue store uses, and for the same reason: a script's own location is
|
||||||
|
# a fact about the installation, cwd is a fact about the last `cd`. Walking up
|
||||||
|
# from __file__ hands every script in both layers one answer no matter where it
|
||||||
|
# is invoked from.
|
||||||
|
#
|
||||||
|
# The twenty lines below are duplicated from the issue domain rather than
|
||||||
|
# imported from it. Two domains that do not know about each other is worth more
|
||||||
|
# than the duplication is worth saving: skills/page must keep working with
|
||||||
|
# skills/issue deleted, exactly as skills/issue keeps working with skills/sync
|
||||||
|
# deleted.
|
||||||
|
|
||||||
|
STORE_PARTS = ("tmp", "wiki")
|
||||||
|
|
||||||
|
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||||
|
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||||
|
# git; the agents-sync hook only ever puts one at a repository root.
|
||||||
|
REPO_MARKERS = (".git", "AGENTS.md")
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
MANIFEST = ".pages.json"
|
||||||
|
|
||||||
|
# Written here; read here. Everything else in an entry belongs to the wiki
|
||||||
|
# layer and is passed through untouched.
|
||||||
|
DOMAIN_KEYS = ("title", "order", "source")
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root(start):
|
||||||
|
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
|
||||||
|
d = os.path.abspath(start)
|
||||||
|
while True:
|
||||||
|
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
|
||||||
|
return d
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
return None
|
||||||
|
d = parent
|
||||||
|
|
||||||
|
|
||||||
|
def store_root(start=None):
|
||||||
|
"""Absolute path of the wiki cache root.
|
||||||
|
|
||||||
|
`start` overrides the anchor so the resolution can be exercised against a
|
||||||
|
scratch tree. Outside a repository, cwd gets a turn, then the historical
|
||||||
|
cwd-relative location stands — made absolute so an error can name the
|
||||||
|
directory it really looked in."""
|
||||||
|
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||||
|
root = repo_root(anchor)
|
||||||
|
if root:
|
||||||
|
return os.path.join(root, *STORE_PARTS)
|
||||||
|
return os.path.abspath(os.path.join(*STORE_PARTS))
|
||||||
|
|
||||||
|
|
||||||
|
WIKI_ROOT = store_root()
|
||||||
|
|
||||||
|
|
||||||
|
def space_root(space, root=None):
|
||||||
|
"""Directory of one space. `space` is an opaque relative path — it may
|
||||||
|
contain `/` (it usually does) and is used as typed."""
|
||||||
|
return os.path.join(root or WIKI_ROOT, *space.split("/"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# names, titles, order
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Characters a title may not carry into a path. `/` is absent on purpose: it is
|
||||||
|
# the hierarchy separator and is split on before this ever applies.
|
||||||
|
_UNSAFE = re.compile(r'[\\:*?"<>|\x00-\x1f]+')
|
||||||
|
# Inline code in a heading is markup, not a name: `Inventory — \`P-NN\`` is a
|
||||||
|
# page called "Inventory — P-NN", and a page list does not render markdown.
|
||||||
|
_MARKUP = re.compile(r"[`*_]+")
|
||||||
|
# Dropped from a PATH but kept in a title. An apostrophe in "Don't send to this
|
||||||
|
# one" belongs in the name and does not belong in something a shell has to
|
||||||
|
# quote.
|
||||||
|
_PATH_NOISE = re.compile(r"['‘’\"“”,]+")
|
||||||
|
_DASHES = re.compile(r"-{2,}")
|
||||||
|
_ORDER = re.compile(r"^(\d+)[-_. ]+(.*)$")
|
||||||
|
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def order_of(name):
|
||||||
|
"""The `NN-` sort key on a file or directory name, or None.
|
||||||
|
|
||||||
|
`00-intro.md` -> 0, `02-chain-core.md` -> 2, `handoff.md` -> None. Zero is
|
||||||
|
a real answer and not None; callers distinguish them."""
|
||||||
|
m = _ORDER.match(strip_ext(name))
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ext(name):
|
||||||
|
stem, ext = os.path.splitext(name)
|
||||||
|
return stem if ext.lower() in (".md", ".markdown") else name
|
||||||
|
|
||||||
|
|
||||||
|
def strip_order(name):
|
||||||
|
"""`02-chain-core` -> `chain-core`; a name that is only digits is left
|
||||||
|
alone, because stripping it would leave nothing to call the page."""
|
||||||
|
m = _ORDER.match(strip_ext(name))
|
||||||
|
return m.group(2) if m and m.group(2) else strip_ext(name)
|
||||||
|
|
||||||
|
|
||||||
|
def title_from_name(name):
|
||||||
|
"""Fallback title: the file or directory name made readable.
|
||||||
|
|
||||||
|
`02-chain-core.md` -> `Chain core`. Only the first letter is raised —
|
||||||
|
title-casing would wreck `Q-01`, `sqlc`, `APNs`, and every other name that
|
||||||
|
already knows how it is spelled."""
|
||||||
|
t = strip_order(name).replace("_", " ").replace("-", " ").strip()
|
||||||
|
t = re.sub(r"\s+", " ", t)
|
||||||
|
return t[:1].upper() + t[1:] if t else t
|
||||||
|
|
||||||
|
|
||||||
|
def title_from_body(text):
|
||||||
|
"""The document's first markdown heading, or None.
|
||||||
|
|
||||||
|
Preferred over the file name because it is what a human wrote for a human:
|
||||||
|
`03-q-01-do-we-know-the-chain-participant-by-name.md` opens with
|
||||||
|
`## Q-01. Do We Know the Chain Participant by Name`, and there is no
|
||||||
|
mechanical route from the first string to the second. Only the first
|
||||||
|
heading is consulted, and only before any prose — a heading further down is
|
||||||
|
a section, not a name."""
|
||||||
|
for line in text.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
m = _HEADING.match(line)
|
||||||
|
return m.group(1).strip() if m else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_title(title):
|
||||||
|
"""Make a string safe to be one title SEGMENT.
|
||||||
|
|
||||||
|
`/` becomes `-`: a slash inside a heading would silently invent a level of
|
||||||
|
hierarchy that the author did not ask for, and inventing structure is worse
|
||||||
|
than losing a slash."""
|
||||||
|
t = _MARKUP.sub("", _UNSAFE.sub("", title.replace("/", "-")))
|
||||||
|
return re.sub(r"\s+", " ", t).strip(" .-") or "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def join_title(*parts):
|
||||||
|
"""Join title segments with the hierarchy separator, dropping empties."""
|
||||||
|
return "/".join(p for p in parts if p)
|
||||||
|
|
||||||
|
|
||||||
|
def path_segment(segment):
|
||||||
|
"""One title segment as one path component."""
|
||||||
|
s = _PATH_NOISE.sub("", _MARKUP.sub("", _UNSAFE.sub("", segment)))
|
||||||
|
s = re.sub(r"\s+", "-", s.replace("/", "-").strip())
|
||||||
|
return _DASHES.sub("-", s).strip("-.") or "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def path_for_title(title):
|
||||||
|
"""Relative path, inside a space, for a title. Always ends in `.md`."""
|
||||||
|
parts = [path_segment(p) for p in title.split("/") if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
parts = ["untitled"]
|
||||||
|
return os.path.join(*parts) + ".md"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the manifest
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def blank_manifest(space):
|
||||||
|
return {"space": space, "pages": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_path(space, root=None):
|
||||||
|
return os.path.join(space_root(space, root), MANIFEST)
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(space, root=None):
|
||||||
|
"""The space's manifest, or a blank one.
|
||||||
|
|
||||||
|
A missing manifest and an empty one are the same thing to every caller here
|
||||||
|
— but they are NOT the same thing to a caller deciding whether to print
|
||||||
|
"no such space". That distinction is `os.path.isdir(space_root(...))`, and
|
||||||
|
the commands make it themselves rather than reading it out of a dict."""
|
||||||
|
p = manifest_path(space, root)
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
return blank_manifest(space)
|
||||||
|
with open(p, encoding="utf-8") as f:
|
||||||
|
m = json.load(f)
|
||||||
|
m.setdefault("space", space)
|
||||||
|
m.setdefault("pages", {})
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def save_manifest(manifest, root=None):
|
||||||
|
"""Write the manifest, keys sorted, one page per line-block.
|
||||||
|
|
||||||
|
Sorted and indented because this file lands in a diff every time anything
|
||||||
|
syncs, and a diff nobody can read is a diff nobody checks."""
|
||||||
|
p = manifest_path(manifest["space"], root)
|
||||||
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||||
|
ordered = {"space": manifest["space"], "pages": {}}
|
||||||
|
for path, e in sorted(manifest.get("pages", {}).items()):
|
||||||
|
ordered["pages"][path] = {k: e[k] for k in DOMAIN_KEYS if k in e}
|
||||||
|
ordered["pages"][path].update(
|
||||||
|
{k: v for k, v in sorted(e.items()) if k not in DOMAIN_KEYS})
|
||||||
|
with open(p, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(ordered, f, ensure_ascii=False, indent=2, sort_keys=False)
|
||||||
|
f.write("\n")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def entry(title, order=None, source=None, **extra):
|
||||||
|
"""A manifest entry. Domain keys first, passthrough after — the same
|
||||||
|
render order the issue layer uses, for the same reason: it makes a diff of
|
||||||
|
the file readable."""
|
||||||
|
e = {"title": title}
|
||||||
|
if order is not None:
|
||||||
|
e["order"] = order
|
||||||
|
if source is not None:
|
||||||
|
e["source"] = source
|
||||||
|
e.update({k: v for k, v in extra.items() if v is not None})
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def find_by_source(manifest, source, prefix=""):
|
||||||
|
"""(relpath, entry) for the page imported from this source file, or
|
||||||
|
(None, None).
|
||||||
|
|
||||||
|
The path is derived from the title, so a retitle moves it — and looking a
|
||||||
|
page up by its new path would find nothing, treat it as new, and publish a
|
||||||
|
duplicate beside the page it was meant to rename. Source is the one link
|
||||||
|
that survives a rename, which is why it is recorded at all.
|
||||||
|
|
||||||
|
Scoped by title prefix, so importing the same directory twice under two
|
||||||
|
prefixes gives two independent trees rather than one fighting over itself.
|
||||||
|
"""
|
||||||
|
for path, e in manifest.get("pages", {}).items():
|
||||||
|
if e.get("source") != source:
|
||||||
|
continue
|
||||||
|
if prefix and not (e.get("title", "") == prefix
|
||||||
|
or e.get("title", "").startswith(prefix + "/")):
|
||||||
|
continue
|
||||||
|
return path, e
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def sort_key(relpath, e):
|
||||||
|
"""Order a tree for display and for an index.
|
||||||
|
|
||||||
|
Directory by directory, `order` first and unnumbered pages after — an
|
||||||
|
explicit `NN-` is a decision, its absence is not. Ties break on title so
|
||||||
|
the output is stable."""
|
||||||
|
d = os.path.dirname(relpath)
|
||||||
|
o = e.get("order")
|
||||||
|
return (d, 0 if o is not None else 1, o if o is not None else 0,
|
||||||
|
e.get("title", relpath))
|
||||||
|
|
||||||
|
|
||||||
|
def sorted_pages(manifest):
|
||||||
|
"""[(relpath, entry)] in tree order."""
|
||||||
|
return sorted(manifest.get("pages", {}).items(),
|
||||||
|
key=lambda kv: sort_key(kv[0], kv[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def by_title(manifest):
|
||||||
|
return {e["title"]: (p, e) for p, e in manifest.get("pages", {}).items()
|
||||||
|
if e.get("title")}
|
||||||
|
|
||||||
|
|
||||||
|
def children_of(manifest, prefix):
|
||||||
|
"""Every page at or under a title prefix.
|
||||||
|
|
||||||
|
The wiki this feeds is flat, so "children" is a prefix test on the title
|
||||||
|
and nothing more — there is no tree to walk, only a naming convention to
|
||||||
|
trust."""
|
||||||
|
out = []
|
||||||
|
for p, e in sorted_pages(manifest):
|
||||||
|
t = e.get("title", "")
|
||||||
|
if t == prefix or t.startswith(prefix + "/"):
|
||||||
|
out.append((p, e))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def body_hash(text):
|
||||||
|
"""sha1 of the exact bytes a page would be published as.
|
||||||
|
|
||||||
|
This is the whole of change detection: a page is worth pushing when what is
|
||||||
|
on disk hashes differently from what was pushed last. No timestamps, no
|
||||||
|
drift model — the same stance the issue store takes."""
|
||||||
|
if isinstance(text, str):
|
||||||
|
text = text.encode("utf-8")
|
||||||
|
return hashlib.sha1(text).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# importing a directory of markdown
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SKIP_DIRS = {".git", ".svn", "__pycache__", "node_modules"}
|
||||||
|
MD_EXT = (".md", ".markdown")
|
||||||
|
|
||||||
|
|
||||||
|
def walk_markdown(src):
|
||||||
|
"""Every markdown file under `src`, as paths relative to it, depth first
|
||||||
|
and sorted so an import is reproducible."""
|
||||||
|
out = []
|
||||||
|
for dirpath, dirnames, filenames in os.walk(src):
|
||||||
|
dirnames[:] = sorted(d for d in dirnames
|
||||||
|
if d not in SKIP_DIRS and not d.startswith("."))
|
||||||
|
rel = os.path.relpath(dirpath, src)
|
||||||
|
rel = "" if rel == "." else rel
|
||||||
|
for f in sorted(filenames):
|
||||||
|
if f.lower().endswith(MD_EXT) and not f.startswith("."):
|
||||||
|
out.append(os.path.join(rel, f) if rel else f)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def title_for_source(relpath, text, prefix=""):
|
||||||
|
"""The title a source file gets on import.
|
||||||
|
|
||||||
|
Three rules, in this order, and the reference doc spells out why:
|
||||||
|
|
||||||
|
1. `order 0` (`00-intro.md`, or a literal `index`/`readme`) is the page for
|
||||||
|
the directory it sits in. Its title comes from the DIRECTORY name, not
|
||||||
|
from its own heading — a child's title must extend its parent's exactly,
|
||||||
|
and `ideas/00-intro.md` opens with "Ideas for chain business
|
||||||
|
requirements", which no child would ever be prefixed by.
|
||||||
|
2. Any other file takes its first heading, sanitized.
|
||||||
|
3. No heading: the file name, made readable.
|
||||||
|
"""
|
||||||
|
parts = relpath.replace(os.sep, "/").split("/")
|
||||||
|
name = parts[-1]
|
||||||
|
dirs = [sanitize_title(title_from_name(d)) for d in parts[:-1]]
|
||||||
|
|
||||||
|
stem = strip_ext(name).lower()
|
||||||
|
if order_of(name) == 0 or stem in ("index", "readme"):
|
||||||
|
# The directory's own page. At the root of the import that is the
|
||||||
|
# prefix itself.
|
||||||
|
return join_title(prefix, *dirs)
|
||||||
|
|
||||||
|
own = title_from_body(text)
|
||||||
|
own = sanitize_title(own) if own else sanitize_title(title_from_name(name))
|
||||||
|
return join_title(prefix, *dirs, own)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_import(src, prefix="", read=None):
|
||||||
|
"""Work out what an import would produce, without writing anything.
|
||||||
|
|
||||||
|
Returns (pages, collisions):
|
||||||
|
pages [{"source", "path", "title", "order", "text"}] in tree order
|
||||||
|
collisions [(path, [title, title, ...])] — two sources landing on one
|
||||||
|
file. Reported, never resolved: the wiki would end up with
|
||||||
|
two pages fighting over one local copy, and picking a winner
|
||||||
|
for the operator is how a discussion loses a document."""
|
||||||
|
def default_read(p):
|
||||||
|
with open(p, encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
read = read or default_read
|
||||||
|
pages, seen = [], {}
|
||||||
|
for rel in walk_markdown(src):
|
||||||
|
source = os.path.join(src, rel)
|
||||||
|
text = read(source)
|
||||||
|
title = title_for_source(rel, text, prefix)
|
||||||
|
path = path_for_title(title)
|
||||||
|
seen.setdefault(path, []).append(title)
|
||||||
|
# `source` is kept relative to the import root, not absolute: it is the
|
||||||
|
# only durable link between a file on the far side and the page it
|
||||||
|
# became, and it has to survive the artifacts directory being moved.
|
||||||
|
pages.append({"source": source, "rel": rel.replace(os.sep, "/"),
|
||||||
|
"path": path, "title": title,
|
||||||
|
"order": order_of(os.path.basename(rel)), "text": text})
|
||||||
|
pages.sort(key=lambda p: sort_key(p["path"], p))
|
||||||
|
collisions = [(p, t) for p, t in sorted(seen.items()) if len(t) > 1]
|
||||||
|
return pages, collisions
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# rendering
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def title_tree(manifest, prefix=""):
|
||||||
|
"""Group pages into a parent -> children map keyed by title.
|
||||||
|
|
||||||
|
Built from the titles, not from the manifest's path order. Those two
|
||||||
|
disagree: on disk `Simple-Chains/System.md` sorts before
|
||||||
|
`Simple-Chains/Ideas/Scale.md`, while in the hierarchy Scale is a
|
||||||
|
grandchild of Simple Chains and System is a child. Nesting has to follow
|
||||||
|
the titles, because the titles are the only hierarchy there is.
|
||||||
|
|
||||||
|
A parent with no page of its own still gets a node: `Simple Chains/Parked`
|
||||||
|
can have children while nothing is published at that title, and dropping
|
||||||
|
its children because it is missing would hide them entirely."""
|
||||||
|
kids, entries = {}, {}
|
||||||
|
for _, e in manifest.get("pages", {}).items():
|
||||||
|
title = e.get("title")
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
if prefix and not (title == prefix or title.startswith(prefix + "/")):
|
||||||
|
continue
|
||||||
|
entries[title] = e
|
||||||
|
parts = title.split("/")
|
||||||
|
# Every ancestor gets a node, so a gap in the chain does not orphan a
|
||||||
|
# subtree.
|
||||||
|
for i in range(len(parts), 0, -1):
|
||||||
|
kids.setdefault("/".join(parts[:i - 1]), set()).add("/".join(parts[:i]))
|
||||||
|
return kids, entries
|
||||||
|
|
||||||
|
|
||||||
|
def render_index(manifest, prefix="", heading=None):
|
||||||
|
"""A table-of-contents page for a space or a subtree.
|
||||||
|
|
||||||
|
Nested markdown list, indented by title depth. The wiki is flat and will
|
||||||
|
not draw this for you, so the index IS the navigation.
|
||||||
|
|
||||||
|
Links: a published page is linked by its `sub_url`, which is the only
|
||||||
|
address Gitea guarantees. A page that has never been pushed has no sub_url
|
||||||
|
yet, so it gets Gitea's own `[[Title]]` wiki-link syntax — which resolves
|
||||||
|
the escaping itself, at render time, on the server. Rebuilding the index
|
||||||
|
after a push upgrades those links to exact ones."""
|
||||||
|
kids, entries = title_tree(manifest, prefix)
|
||||||
|
lines = ["# %s" % (heading or prefix or "Contents"), ""]
|
||||||
|
|
||||||
|
def order_key(title):
|
||||||
|
e = entries.get(title) or {}
|
||||||
|
o = e.get("order")
|
||||||
|
return (0 if o is not None else 1, o if o is not None else 0, title)
|
||||||
|
|
||||||
|
def walk(node, depth):
|
||||||
|
for child in sorted(kids.get(node, ()), key=order_key):
|
||||||
|
e = entries.get(child) or {}
|
||||||
|
label = child.split("/")[-1]
|
||||||
|
sub = e.get("sub_url")
|
||||||
|
link = "[%s](%s)" % (label, sub) if sub else "[[%s|%s]]" % (child, label)
|
||||||
|
lines.append("%s- %s" % (" " * depth, link))
|
||||||
|
walk(child, depth + 1)
|
||||||
|
|
||||||
|
walk(prefix, 0)
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def tree_lines(manifest, mark=None):
|
||||||
|
"""The space as an ascii tree, for a terminal.
|
||||||
|
|
||||||
|
`mark(relpath, entry)` returns a short state tag shown after the title —
|
||||||
|
the wiki layer passes sync state through it, and this module stays unaware
|
||||||
|
of what the tags mean."""
|
||||||
|
out, last_dir = [], None
|
||||||
|
for path, e in sorted_pages(manifest):
|
||||||
|
d = os.path.dirname(path)
|
||||||
|
if d != last_dir:
|
||||||
|
out.append("%s/" % d if d else ".")
|
||||||
|
last_dir = d
|
||||||
|
tag = mark(path, e) if mark else ""
|
||||||
|
out.append(" %-40s %s%s" % (os.path.basename(path),
|
||||||
|
e.get("title", ""),
|
||||||
|
(" " + tag) if tag else ""))
|
||||||
|
return out
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
page_import.py — pull a directory of markdown into a space. Offline.
|
||||||
|
|
||||||
|
This is the "wiki organization" step, and it is the only step where a page gets
|
||||||
|
its name. A discussion produces artifacts wherever the discussion happened:
|
||||||
|
|
||||||
|
~/…/mpns/feat/simple-chains/tmp/simple-chains/
|
||||||
|
handoff.md scope.md
|
||||||
|
ideas/00-intro.md ideas/02-chain-core.md
|
||||||
|
questions/03-q-01-do-we-know-the-chain-participant-by-name.md
|
||||||
|
|
||||||
|
Import copies that tree into a space under `tmp/wiki/`, gives every file a
|
||||||
|
title, and records both in the manifest. Nothing here talks to a wiki; the
|
||||||
|
result is a complete, readable, greppable tree whether or not it is ever
|
||||||
|
published.
|
||||||
|
|
||||||
|
page_import.py --from DIR --space claude-skills/tea --prefix "Simple Chains"
|
||||||
|
|
||||||
|
Simple-Chains/Handoff.md Simple Chains/Handoff
|
||||||
|
Simple-Chains/Ideas.md Simple Chains/Ideas
|
||||||
|
Simple-Chains/Ideas/Chain-core.md Simple Chains/Ideas/Chain core
|
||||||
|
|
||||||
|
Re-importing is safe and is the normal way to refresh: a page already in the
|
||||||
|
manifest keeps its title (a title is a decision, not a derivation) and only its
|
||||||
|
body is replaced. `--retitle` opts into re-deriving titles, which is a rename
|
||||||
|
and, for pages already published, will orphan the old ones — so it is never the
|
||||||
|
default.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
page_import.py --from DIR [--space SPACE] [--prefix TITLE]
|
||||||
|
[--retitle] [--dry-run] [--out DIR]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import page # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg, code=1):
|
||||||
|
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--from", dest="src", required=True,
|
||||||
|
help="directory of markdown to import")
|
||||||
|
ap.add_argument("--space", default="local",
|
||||||
|
help="space to import into (default: local)")
|
||||||
|
ap.add_argument("--prefix", default="",
|
||||||
|
help="title every imported page hangs under")
|
||||||
|
ap.add_argument("--retitle", action="store_true",
|
||||||
|
help="re-derive titles of pages already in the manifest "
|
||||||
|
"(a rename; orphans published pages)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
src = os.path.abspath(a.src)
|
||||||
|
if not os.path.isdir(src):
|
||||||
|
die("not a directory: %s" % a.src)
|
||||||
|
|
||||||
|
root = a.out or page.WIKI_ROOT
|
||||||
|
prefix = page.sanitize_title(a.prefix) if a.prefix else ""
|
||||||
|
|
||||||
|
pages, collisions = page.plan_import(src, prefix)
|
||||||
|
if not pages:
|
||||||
|
die("no markdown found under %s" % src)
|
||||||
|
if collisions:
|
||||||
|
for path, titles in collisions:
|
||||||
|
sys.stderr.write("collision: %s <- %s\n" % (path, " | ".join(titles)))
|
||||||
|
die("%d path collision(s); rename the sources and retry" % len(collisions))
|
||||||
|
|
||||||
|
manifest = page.load_manifest(a.space, root)
|
||||||
|
known = manifest["pages"]
|
||||||
|
dest_root = page.space_root(a.space, root)
|
||||||
|
# Asked before anything is written: nothing should create a space as a
|
||||||
|
# silent side effect of a write, and saying so on stderr is how the
|
||||||
|
# operator learns a typo in --space made a second one.
|
||||||
|
created = not os.path.isdir(dest_root)
|
||||||
|
|
||||||
|
new = changed = same = moved = 0
|
||||||
|
for p in pages:
|
||||||
|
# Looked up by SOURCE, not by path: a retitle moves the path, and a
|
||||||
|
# lookup that missed would treat the page as new and publish a
|
||||||
|
# duplicate beside the one it was meant to rename.
|
||||||
|
prior_path, prior = page.find_by_source(manifest, p["rel"], prefix)
|
||||||
|
if prior is None:
|
||||||
|
prior_path, prior = p["path"], known.get(p["path"])
|
||||||
|
|
||||||
|
# A title already in the manifest is a decision that was made once.
|
||||||
|
# Re-deriving it on every import would let an edited heading silently
|
||||||
|
# rename a published page — which does not rename it, it creates a
|
||||||
|
# second one and abandons the first.
|
||||||
|
title = p["title"] if (a.retitle or not prior) else prior["title"]
|
||||||
|
relpath = page.path_for_title(title)
|
||||||
|
dest = os.path.join(dest_root, relpath)
|
||||||
|
|
||||||
|
state = "new"
|
||||||
|
if prior and relpath != prior_path:
|
||||||
|
state = "moved"
|
||||||
|
elif prior and os.path.isfile(dest):
|
||||||
|
with open(dest, encoding="utf-8") as f:
|
||||||
|
state = "same" if f.read() == p["text"] else "changed"
|
||||||
|
elif prior:
|
||||||
|
state = "changed"
|
||||||
|
|
||||||
|
new += state == "new"
|
||||||
|
changed += state == "changed"
|
||||||
|
same += state == "same"
|
||||||
|
moved += state == "moved"
|
||||||
|
|
||||||
|
print("%-7s %-44s %s" % (state, relpath, title))
|
||||||
|
if a.dry_run:
|
||||||
|
continue
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||||
|
shutil.copyfile(p["source"], dest)
|
||||||
|
# Passthrough keys survive: a re-import must not cost a page its
|
||||||
|
# sub_url, or the next push would publish a duplicate.
|
||||||
|
e = dict(prior or {})
|
||||||
|
e.update(page.entry(title, p["order"], p["rel"]))
|
||||||
|
if state == "moved":
|
||||||
|
# The old copy goes, the entry moves with its bookkeeping intact.
|
||||||
|
# The page in the wiki is still at its old sub_url; the next push
|
||||||
|
# sends the new title, which is what renames it there.
|
||||||
|
old = os.path.join(dest_root, prior_path)
|
||||||
|
if os.path.isfile(old):
|
||||||
|
os.remove(old)
|
||||||
|
known.pop(prior_path, None)
|
||||||
|
# A rename can leave the body byte-identical, and push decides by
|
||||||
|
# body hash alone. Clearing it is what makes the next push send the
|
||||||
|
# new title instead of skipping the page as unchanged.
|
||||||
|
e.pop("pushed", None)
|
||||||
|
known[relpath] = e
|
||||||
|
|
||||||
|
if a.dry_run:
|
||||||
|
print("\ndry run — nothing written")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
path = page.save_manifest(manifest, root)
|
||||||
|
if created:
|
||||||
|
sys.stderr.write("created space %s\n" % dest_root)
|
||||||
|
print("\n%d new, %d changed, %d unchanged%s -> %s"
|
||||||
|
% (new, changed, same,
|
||||||
|
", %d renamed" % moved if moved else "", os.path.dirname(path)))
|
||||||
|
if moved:
|
||||||
|
sys.stderr.write(
|
||||||
|
"%d page(s) renamed. A published page is renamed in the wiki by "
|
||||||
|
"the next push, not by this import.\n" % moved)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
page_index.py — write a table-of-contents page into a space. Offline.
|
||||||
|
|
||||||
|
The wiki this feeds is flat: a title like `Simple Chains/Ideas/Chain core` has
|
||||||
|
hierarchy in its name and nowhere else, and Gitea will not draw you a tree from
|
||||||
|
it. An index page is therefore not a nicety, it is the navigation.
|
||||||
|
|
||||||
|
Written as an ordinary page in the space, so it is pushed by the same command
|
||||||
|
as everything else and needs no special case anywhere downstream. Links are
|
||||||
|
written by TITLE rather than by URL — the wiki resolves those itself, and a
|
||||||
|
link written that way survives every filename-escaping rule this layer
|
||||||
|
deliberately refuses to model.
|
||||||
|
|
||||||
|
page_index.py --space claude-skills/tea --prefix "Simple Chains"
|
||||||
|
-> Simple-Chains.md, title `Simple Chains`
|
||||||
|
|
||||||
|
page_index.py --space claude-skills/tea --title Home
|
||||||
|
-> Home.md, title `Home`, listing the whole space
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
page_index.py [--space SPACE] [--prefix TITLE] [--title TITLE]
|
||||||
|
[--dry-run] [--out DIR]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import page # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg, code=1):
|
||||||
|
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--space", default="local")
|
||||||
|
ap.add_argument("--prefix", default="",
|
||||||
|
help="index only this subtree; also the index's own title")
|
||||||
|
ap.add_argument("--title", help="title for the index page "
|
||||||
|
"(default: --prefix, else Home)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
root = a.out or page.WIKI_ROOT
|
||||||
|
space_dir = page.space_root(a.space, root)
|
||||||
|
if not os.path.isdir(space_dir):
|
||||||
|
die("no such space: %s (looked in %s)" % (a.space, space_dir))
|
||||||
|
|
||||||
|
manifest = page.load_manifest(a.space, root)
|
||||||
|
prefix = page.sanitize_title(a.prefix) if a.prefix else ""
|
||||||
|
title = a.title or prefix or "Home"
|
||||||
|
|
||||||
|
body = page.render_index(manifest, prefix, heading=title)
|
||||||
|
relpath = page.path_for_title(title)
|
||||||
|
|
||||||
|
if a.dry_run:
|
||||||
|
sys.stdout.write(body)
|
||||||
|
print("-> %s (%s)" % (relpath, title))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
dest = os.path.join(space_dir, relpath)
|
||||||
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||||
|
with open(dest, "w", encoding="utf-8") as f:
|
||||||
|
f.write(body)
|
||||||
|
|
||||||
|
# Carries the entry's passthrough keys forward: rebuilding an index must
|
||||||
|
# update the page that is already published, never publish a second one.
|
||||||
|
prior = manifest["pages"].get(relpath, {})
|
||||||
|
e = dict(prior)
|
||||||
|
e.update(page.entry(title, prior.get("order")))
|
||||||
|
manifest["pages"][relpath] = e
|
||||||
|
page.save_manifest(manifest, root)
|
||||||
|
|
||||||
|
n = len(page.children_of(manifest, prefix) if prefix
|
||||||
|
else page.sorted_pages(manifest))
|
||||||
|
print("%s -> %s (%d entr%s)" % (title, relpath, n - 1,
|
||||||
|
"y" if n - 1 == 1 else "ies"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
page_ls.py — show what a space holds. Offline.
|
||||||
|
|
||||||
|
The tree, the titles, and one state tag per page. The tag is the only place
|
||||||
|
this layer acknowledges that a wiki exists, and it reads it the way the issue
|
||||||
|
index reads `origin:` — as an opaque fact recorded by somebody else:
|
||||||
|
|
||||||
|
local never published; a complete state, not a pending one
|
||||||
|
synced published, and the file matches what was pushed
|
||||||
|
ahead published, and the local file has changed since
|
||||||
|
? published, but nothing recorded what was pushed
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
page_ls.py [--space SPACE] [--prefix TITLE] [--titles] [--out DIR]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import page # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg, code=1):
|
||||||
|
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def state_of(space_dir, relpath, e):
|
||||||
|
if not e.get("sub_url"):
|
||||||
|
return "local"
|
||||||
|
pushed = e.get("pushed")
|
||||||
|
if not pushed:
|
||||||
|
return "?"
|
||||||
|
full = os.path.join(space_dir, relpath)
|
||||||
|
if not os.path.isfile(full):
|
||||||
|
return "missing"
|
||||||
|
with open(full, encoding="utf-8") as f:
|
||||||
|
return "synced" if page.body_hash(f.read()) == pushed else "ahead"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--space", default="local")
|
||||||
|
ap.add_argument("--prefix", default="", help="only titles at or under this")
|
||||||
|
ap.add_argument("--titles", action="store_true",
|
||||||
|
help="print one title per line and nothing else")
|
||||||
|
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
root = a.out or page.WIKI_ROOT
|
||||||
|
space_dir = page.space_root(a.space, root)
|
||||||
|
# "Does not exist" and "is empty" are different answers and get different
|
||||||
|
# messages — an empty space is a space somebody made on purpose.
|
||||||
|
if not os.path.isdir(space_dir):
|
||||||
|
die("no such space: %s (looked in %s)" % (a.space, space_dir))
|
||||||
|
|
||||||
|
manifest = page.load_manifest(a.space, root)
|
||||||
|
pages = (page.children_of(manifest, a.prefix) if a.prefix
|
||||||
|
else page.sorted_pages(manifest))
|
||||||
|
if not pages:
|
||||||
|
print("space %s is empty" % a.space if not a.prefix
|
||||||
|
else "nothing at or under %r" % a.prefix)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if a.titles:
|
||||||
|
for _, e in pages:
|
||||||
|
print(e.get("title", ""))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sub = {p: e for p, e in pages}
|
||||||
|
view = dict(manifest, pages=sub)
|
||||||
|
for line in page.tree_lines(view, mark=lambda p, e: state_of(space_dir, p, e)):
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
for p, e in pages:
|
||||||
|
s = state_of(space_dir, p, e)
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
print("\n%d page(s): %s" % (len(pages),
|
||||||
|
", ".join("%d %s" % (v, k)
|
||||||
|
for k, v in sorted(counts.items()))))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
---
|
||||||
|
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`, plus `<id>.comments.md` when the thread is not empty |
|
||||||
|
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
|
||||||
|
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||||
|
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
`--out` defaults to `issue.ISSUE_ROOT` on every one of them — the domain layer's
|
||||||
|
`<repo root>/tmp/issues`, resolved from the scripts' own location rather than
|
||||||
|
cwd. Both layers therefore address the same store by construction, from any
|
||||||
|
directory. Pass `--out` to override; a relative one stays relative to cwd. Only
|
||||||
|
`pull.py` will create a missing store, and it says so on stderr.
|
||||||
|
|
||||||
|
## Identity mapping
|
||||||
|
|
||||||
|
The local id is a slug; Gitea's is a number. While a working copy exists, the
|
||||||
|
pair is in the file:
|
||||||
|
|
||||||
|
```
|
||||||
|
origin: gitea
|
||||||
|
gitea: claude-skills/tea#42
|
||||||
|
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||||
|
synced: 2026-08-09T18:40:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
But the file is deleted on push, so the pair also lives in two places that
|
||||||
|
outlast it: `tmp/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
|
||||||
|
marker in the issue body on the Gitea side. See [How the slug comes
|
||||||
|
back](#how-the-slug-comes-back).
|
||||||
|
|
||||||
|
`.remote.json` used to be described as an index over the files. It is not one
|
||||||
|
any more — the files are a subset of what it knows, and its entries deliberately
|
||||||
|
outlive them. It is the local **ledger**, and `_gitea.rebuild_map` merges into it
|
||||||
|
rather than reconstructing it, so a rebuild can never drop a pushed issue.
|
||||||
|
Nothing prunes it: "no file" no longer means "no such issue". Delete it anyway
|
||||||
|
and nothing is lost — the next pull reads the slug off the marker and writes the
|
||||||
|
entry back.
|
||||||
|
|
||||||
|
A retitled issue keeps its slug: neither record is keyed by the title.
|
||||||
|
|
||||||
|
## 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 is how a pushed issue comes back.** Push deleted the file, so this is
|
||||||
|
not refreshing a copy you kept — it is how the copy comes to exist. It lands
|
||||||
|
under the same slug it had before, even after a rename in Gitea and even on a
|
||||||
|
machine that has never seen the issue; see [How the slug comes
|
||||||
|
back](#how-the-slug-comes-back).
|
||||||
|
|
||||||
|
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
||||||
|
local edits are lost, with one exception: [checkbox
|
||||||
|
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
|
||||||
|
disk.
|
||||||
|
|
||||||
|
**Closed issues stay out of the store.** In filter mode they are enumerated
|
||||||
|
but not written: `--state all` still shows the whole picture, only `--state
|
||||||
|
closed` puts one on disk, and the number left out goes to stderr. An issue
|
||||||
|
already on disk is refreshed either way — the local copy learns it was closed
|
||||||
|
instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a
|
||||||
|
closed issue as always, because an address is not a bulk read.
|
||||||
|
|
||||||
|
**Comments come with every pull** — there is no flag. An issue that has a
|
||||||
|
thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter
|
||||||
|
mode alike, and the issue's output line says how many. An issue with none
|
||||||
|
costs nothing: the count arrives in the list payload, so no request is made
|
||||||
|
and no file is written — and a file left over from a thread that has since
|
||||||
|
been emptied is deleted. `--cached` skips the thread along with the body, so a
|
||||||
|
skipped issue makes no request at all.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Checkboxes are the one exception
|
||||||
|
|
||||||
|
A checkbox is state, not prose, and it is the one thing a pull does **not**
|
||||||
|
overwrite. For a checkbox line whose **text** matches a line in the local copy,
|
||||||
|
`[x]` wins from whichever side has it — tick it in the web UI, tick it locally,
|
||||||
|
tick it in both, the tick survives.
|
||||||
|
|
||||||
|
| part of the body | what a pull does to it |
|
||||||
|
|---|---|
|
||||||
|
| prose, headings, everything not a checkbox | overwritten from the server, whole, as before |
|
||||||
|
| a checkbox whose text is in the local copy | `[x]` from **either** side wins |
|
||||||
|
| a checkbox whose text is not in the local copy | taken from the server as it stands, ticked or not |
|
||||||
|
| any issue the store has never seen | written exactly as the server sent it |
|
||||||
|
|
||||||
|
This is not drift tracking — [Drift](#drift) stands. A tick is **monotone**: an
|
||||||
|
item only travels `[ ]` → `[x]`, so joining the two sides is a set union, not a
|
||||||
|
conflict to resolve. No base version is kept and nothing is compared against
|
||||||
|
one; one rule for one line type replaces the whole mechanism.
|
||||||
|
|
||||||
|
**The price, and it is real: a box unticked in the web UI comes back on the next
|
||||||
|
pull.** Unticking is not monotone, so the union cannot see it. Untick locally,
|
||||||
|
then `push.py --update` — the body goes up whole and the server follows.
|
||||||
|
|
||||||
|
Matching is on the item's text after the domain parser has stripped it and
|
||||||
|
rejoined wrapped lines with single spaces, so rewrapping a long item keeps its
|
||||||
|
tick. Rewording one does not: different text is a different item. The same text
|
||||||
|
twice in a body is read as a set — one ticked local copy ticks every server line
|
||||||
|
with that text.
|
||||||
|
|
||||||
|
The parsing is `/tea:issue`'s (`issue.checkboxes` / `issue.set_checkbox`),
|
||||||
|
imported, never reimplemented here. The rule itself is
|
||||||
|
`map.merge_checkbox_state`: pure, and testable without a Gitea anywhere.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
**A successful push DELETES the local file** — `tmp/issues/<id>.md` and
|
||||||
|
`<id>.comments.md` — and prints the number and URL the issue now lives at:
|
||||||
|
|
||||||
|
```
|
||||||
|
created wire-sqlc-appclick #42 https://git.noodles.cam/claude-skills/tea/issues/42
|
||||||
|
dropped /repo/tmp/issues/wire-sqlc-appclick.md
|
||||||
|
pull.py 42 to work on it again
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the tracker has the issue, the tracker *is* the issue. What is left in the
|
||||||
|
store is what has not left this machine. There is no second copy, so there is
|
||||||
|
nothing to reconcile and no "is mine the fresh one?" to answer — see
|
||||||
|
[Drift](#drift).
|
||||||
|
|
||||||
|
**`--update` deletes too. One rule, no exception.** A PATCH is a push; an issue
|
||||||
|
that has just been sent is no more local than one that was just created. Edit an
|
||||||
|
issue by pulling it, changing it, pushing it — the copy is gone again after.
|
||||||
|
|
||||||
|
### What has to be true before anything is deleted
|
||||||
|
|
||||||
|
In order, and the delete is last:
|
||||||
|
|
||||||
|
1. the transport returned — `tea` ran and exited 0 (a non-2xx exits the run), and
|
||||||
|
2. the answer is an object carrying a positive integer `number`, and on
|
||||||
|
`--update` **the same number that was PATCHed** (`push.confirmed_number`), and
|
||||||
|
3. `.remote.json` has been written with number → slug.
|
||||||
|
|
||||||
|
Network down, a 422, an empty body, an answer for a different issue: the file is
|
||||||
|
still there and the run stops with the path in the error. An `origin: local`
|
||||||
|
issue that was not sent — including a local-only dependency that push only read
|
||||||
|
to warn about — is never touched. `--dry-run` deletes nothing and sends nothing.
|
||||||
|
|
||||||
|
### How the slug comes back
|
||||||
|
|
||||||
|
The slug is the issue's identity and the format promises it is stable for life,
|
||||||
|
so it cannot live only in a file that push is about to delete. Two records, and
|
||||||
|
the durable one is not local:
|
||||||
|
|
||||||
|
| where | survives | how |
|
||||||
|
|---|---|---|
|
||||||
|
| `<!-- tea:id wire-sqlc-appclick -->` | a rename in the web UI, a lost `.remote.json`, a fresh clone, another machine | first line of the **tracker-side** body; an HTML comment, so Gitea renders nothing |
|
||||||
|
| `tmp/issues/.remote.json` | the file being deleted | number → slug, written before the delete |
|
||||||
|
|
||||||
|
`pull.py` consults the ledger first (it is the one that knows about files on
|
||||||
|
disk right now), then the marker, then falls back to slugifying the title for an
|
||||||
|
issue filed in the web UI that has never had a local name. A marker is only
|
||||||
|
taken at its word when that slug is free — it never overwrites an issue already
|
||||||
|
in the store.
|
||||||
|
|
||||||
|
**The marker never appears in the local file.** `map.to_payload` puts exactly
|
||||||
|
one at the top on the way up, `map.from_api` strips every one on the way down.
|
||||||
|
Strip-all-then-prepend-one is the whole mechanism, which is why a body cannot
|
||||||
|
accumulate them however many round trips it makes, and why a body that somehow
|
||||||
|
gained two is cleaned on the next pull.
|
||||||
|
|
||||||
|
`depends:` survives the same round trip through Gitea's native links (below):
|
||||||
|
push writes them, `pull.py --deps` reads them back, and the ledger turns the
|
||||||
|
numbers into the slugs they had here.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Issues go up in topological order, dependencies first, and **the graph goes up
|
||||||
|
with them**. Once an issue has its number, every `depends:` entry that also has
|
||||||
|
one becomes a native Gitea link, so the tracker shows the blocking panel and
|
||||||
|
refuses to close a blocked issue before its blocker.
|
||||||
|
|
||||||
|
The two directions are symmetric, and they use the same endpoint:
|
||||||
|
|
||||||
|
| | direction | endpoint |
|
||||||
|
|---|---|---|
|
||||||
|
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
|
||||||
|
| `pull.py --deps` | native links → `depends:` | `GET …/issues/{n}/dependencies` |
|
||||||
|
|
||||||
|
The POST body is Gitea's `IssueMeta` — `{"index", "owner", "repo"}` naming the
|
||||||
|
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
|
||||||
|
url depend on the issue in the form"). `owner`/`repo` travel with it, so a
|
||||||
|
dependency in another repo links correctly.
|
||||||
|
|
||||||
|
- Topological order means the blocker already has its number — no second pass.
|
||||||
|
- A link the tracker already has is skipped: push GETs the existing ones first,
|
||||||
|
so a repeat push is a no-op and a 409 never happens. Should a link fail
|
||||||
|
anyway, it is a warning, not a dead run — the issues are already created.
|
||||||
|
- `--update` carries links that appeared in `depends:` after the first push.
|
||||||
|
- `--dry-run` prints every link it would make (`#?` for a number this run has
|
||||||
|
not handed out yet) and makes no request at all.
|
||||||
|
- **Removing a link is out of scope.** Push only adds. A dependency deleted
|
||||||
|
from `depends:` leaves its Gitea link standing; drop it in the web UI or with
|
||||||
|
`tea api -X DELETE …/issues/N/dependencies`.
|
||||||
|
|
||||||
|
A dependency that is still local-only is reported, not silently dropped: it has
|
||||||
|
no number, so it gets no link. The body's `## Depends on` prose is sent verbatim
|
||||||
|
either way — nothing is lost, but the tracker shows no edge 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.
|
||||||
|
|
||||||
|
That is per-push and piecemeal: a repo only ever grows the labels its issues
|
||||||
|
happened to use, so filtering by `type/bug` in the web UI stays impossible
|
||||||
|
until someone pushes a bug. `labels.py` lays down the whole set — the 11
|
||||||
|
`type/*` and `severity/*` names — in one run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-base-dir>/scripts/labels.py --dry-run # the plan, no writes
|
||||||
|
python3 <skill-base-dir>/scripts/labels.py # create what is missing
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads the repo's labels first. An exactly-matching name is never re-created
|
||||||
|
and never patched. A **lookalike** — `bug`, `Bug`, `type: bug`, `kind/bug` —
|
||||||
|
is reported with its id and left alone: renaming somebody else's label is a
|
||||||
|
decision, not a migration. A color or `exclusive` that drifted is printed, and
|
||||||
|
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
|
||||||
|
`comp/*` are open-ended by design and stay push-created.
|
||||||
|
|
||||||
|
A milestone must already exist in the repo — push attaches, it does not create.
|
||||||
|
|
||||||
|
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
|
||||||
|
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
||||||
|
and writes it back into the issue file; a value already there is never
|
||||||
|
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
|
||||||
|
a git repo no `ref` is sent and a warning names the issues that went up without
|
||||||
|
one. Reading the branch is the only thing these scripts ask git for — they
|
||||||
|
never check out, create, or write anything.
|
||||||
|
|
||||||
|
## What crosses the boundary, and what does not
|
||||||
|
|
||||||
|
| domain | Gitea | note |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` (slug) | `<!-- tea:id … -->` | first line of the tracker-side body; stripped out of the local copy |
|
||||||
|
| title | `title` | verbatim, both directions |
|
||||||
|
| body | `body` | verbatim up except the marker; verbatim down except the marker and checkbox state, which is unioned |
|
||||||
|
| `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` | native links | slugs here, `IssueMeta` there; push writes them, `pull --deps` reads them |
|
||||||
|
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
||||||
|
| — | `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. The edge the
|
||||||
|
tracker acts on is the native link, not the text — which is exactly why the
|
||||||
|
text can be left alone.
|
||||||
|
|
||||||
|
Comments are **pull-only** in the store: `<id>.comments.md` is written by
|
||||||
|
`pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea.
|
||||||
|
|
||||||
|
## Drift
|
||||||
|
|
||||||
|
There is none tracked, and since push started deleting what it sends there is
|
||||||
|
very little left to track. A published issue has **one** copy — Gitea's —
|
||||||
|
except while somebody is working on it, and that window closes at the next
|
||||||
|
push. Nothing watches Gitea, nothing reconciles, nothing warns that a synced
|
||||||
|
issue changed upstream. `synced:` tells you how old your working copy is;
|
||||||
|
`remote-updated:` what the server said at that moment. Re-pull when it matters,
|
||||||
|
and push when you are done so there is nothing to be stale.
|
||||||
|
|
||||||
|
The old question — "I edited this locally, does the server have it, whose text
|
||||||
|
is newer?" — is answered by the store's contents rather than by a mechanism: a
|
||||||
|
file that is here has not been pushed.
|
||||||
|
|
||||||
|
Checkbox state is not an exception to this. The union a pull applies reads only
|
||||||
|
the two bodies in front of it — there is no base version, no history, and no
|
||||||
|
way for it to report that anything diverged. One rule for one line type,
|
||||||
|
precisely so the mechanism this section rules out is not needed.
|
||||||
|
|
||||||
|
## 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,400 @@
|
|||||||
|
#!/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, and the paths of the store-side files this layer writes. All of
|
||||||
|
it is transport bookkeeping, not domain data — the domain never reads any of
|
||||||
|
it, and losing the map still costs a re-pull and not information: the slug it
|
||||||
|
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
|
||||||
|
pull rebuilds the entry from the tracker. See `rebuild_map`.
|
||||||
|
"""
|
||||||
|
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 []
|
||||||
|
|
||||||
|
|
||||||
|
def native_dep_pairs(login, base, number):
|
||||||
|
"""The same links as {(owner/repo, number)} — what a repeat push compares
|
||||||
|
against so it does not POST a link the tracker already has.
|
||||||
|
|
||||||
|
A bare number is ambiguous the moment a dependency lives in another repo,
|
||||||
|
and IssueMeta lets it, so the repo travels with it. The pair is a transport
|
||||||
|
fact; formatting it as `owner/repo#42` is map.py's job, not this module's."""
|
||||||
|
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
|
||||||
|
out = set()
|
||||||
|
for i in got if isinstance(got, list) else []:
|
||||||
|
repo = (i.get("repository") or {}).get("full_name") or ""
|
||||||
|
if "number" in i:
|
||||||
|
out.add((repo, int(i["number"])))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
|
||||||
|
"""Make issue `number` depend on `dep_repo#dep_number`. True on success.
|
||||||
|
|
||||||
|
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
|
||||||
|
|
||||||
|
POST /repos/{owner}/{repo}/issues/{index}/dependencies
|
||||||
|
body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
|
||||||
|
"Make the issue in the url depend on the issue in the form."
|
||||||
|
|
||||||
|
The URL names the blocked issue and the body the blocker, which is the same
|
||||||
|
direction native_deps reads back ("all issues that block this issue"). A
|
||||||
|
link that already exists answers 409, so a failure here is reported and not
|
||||||
|
fatal: one missing cross-link must not abort a push that has already
|
||||||
|
created issues. Callers pre-filter with native_dep_pairs."""
|
||||||
|
owner, _, name = (dep_repo or "").partition("/")
|
||||||
|
if not owner or not name:
|
||||||
|
return False
|
||||||
|
payload = {"index": int(dep_number), "owner": owner, "repo": name}
|
||||||
|
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
|
||||||
|
payload_name="dep-%d-%d" % (number, dep_number),
|
||||||
|
out_root=out_root, allow_fail=True)
|
||||||
|
return got is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# store-side files this layer owns
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# The issue file itself is the domain's (`issue.path_of`). The one file the sync
|
||||||
|
# layer puts beside it is named here, in one place, because three commands have
|
||||||
|
# to agree on it: pull.py writes the thread, comment.py refetches it, push.py
|
||||||
|
# deletes it along with the issue it just sent.
|
||||||
|
|
||||||
|
def comments_path(root, id):
|
||||||
|
"""An issue's comment thread — beside it, under the same slug.
|
||||||
|
|
||||||
|
A path, not a concept the domain needs: a thread is pulled from Gitea and
|
||||||
|
never pushed back, so the domain has no reason to know the file exists."""
|
||||||
|
return os.path.join(root, "%s.comments.md" % id)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 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"} — the local slug ledger.
|
||||||
|
|
||||||
|
Entries outlive the files they name, and that is now the normal case rather
|
||||||
|
than a leak: `push.py` deletes an issue's file the moment Gitea confirms it,
|
||||||
|
and the entry it leaves behind is what lets the next `pull.py 42` land on
|
||||||
|
the same slug. Nothing prunes them, because "no file" no longer means "no
|
||||||
|
such issue". A stale entry costs one json line and is corrected the next
|
||||||
|
time that number is pulled."""
|
||||||
|
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):
|
||||||
|
"""Fold the `gitea:` fields still on disk into the id map. Returns it.
|
||||||
|
|
||||||
|
This used to say "the files are the source of truth; .remote.json is only an
|
||||||
|
index over them", and that stopped being true the day push started deleting
|
||||||
|
the file it had just sent. A pushed issue leaves no `gitea:` field behind to
|
||||||
|
read, so the files are now a SUBSET of what the map knows, and a rebuild
|
||||||
|
from them alone would throw away every entry it cannot see.
|
||||||
|
|
||||||
|
So the contradiction is resolved by moving the source of truth, not by
|
||||||
|
keeping this function honest about files:
|
||||||
|
|
||||||
|
Gitea the issue, and — in `<!-- tea:id … -->` — its slug
|
||||||
|
.remote.json a local number -> slug ledger, a cache of that marker
|
||||||
|
tmp/issues/*.md whatever happens to be checked out right now
|
||||||
|
|
||||||
|
Which makes this a MERGE and never a replacement: it starts from what is
|
||||||
|
already recorded and adds what the remaining files say. What it cannot
|
||||||
|
recover — a pushed-and-dropped issue whose ledger entry was also lost — is
|
||||||
|
not lost either; the next `pull.py <n>` reads the slug off the marker and
|
||||||
|
writes the entry back."""
|
||||||
|
m = load_map(root)
|
||||||
|
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,101 @@
|
|||||||
|
#!/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: <repo>/tmp/issues)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
root = args.out
|
||||||
|
if not issue.store_exists(root):
|
||||||
|
_gitea.die("store %s does not exist — nothing was created" % root)
|
||||||
|
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,236 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
labels.py — put the canonical label set into a repository, in one run.
|
||||||
|
|
||||||
|
Every `type/*` and every `severity/*` the domain taxonomy defines, created up
|
||||||
|
front instead of trickling in as a side effect of whichever push first happens
|
||||||
|
to use one. Until a name exists in the repository nobody can filter by it in
|
||||||
|
the web UI, so somebody makes their own with a foreign color and without
|
||||||
|
`exclusive`, and the set arrives in pieces over months.
|
||||||
|
|
||||||
|
labels.py --dry-run print the plan, write nothing
|
||||||
|
labels.py create whatever is missing
|
||||||
|
labels.py --fix also patch color / `exclusive` drift
|
||||||
|
labels.py --repo owner/repo outside the repository's own checkout
|
||||||
|
|
||||||
|
No label name is spelled out in this file. The names are assembled from the
|
||||||
|
domain — issue.TYPES, issue.SEVERITIES, issue.EXCLUSIVE_NS — and painted by
|
||||||
|
map.label_specs; add a type over in skills/issue and the next run creates it.
|
||||||
|
`tea labels create` cannot set `exclusive` (tea 0.14.2), so creation goes
|
||||||
|
through `tea api`.
|
||||||
|
|
||||||
|
The repository's own labels are read before anything is written. A name that
|
||||||
|
matches exactly is left alone — never re-created, never patched; a color or
|
||||||
|
`exclusive` that disagrees with the spec is reported, and corrected only under
|
||||||
|
--fix. A name that merely RESEMBLES a canonical one (the same tail, up to
|
||||||
|
case, separator and whatever namespace is in front: `X`, `x`, `kind/x`,
|
||||||
|
`type: x` against `type/x`) is reported with its id and never touched —
|
||||||
|
renaming somebody else's label is a decision, not a step.
|
||||||
|
|
||||||
|
Out of scope by design: `tech/*` and `comp/*`, which are open-ended and get
|
||||||
|
created by push as they come up, and deleting or renaming anything at all.
|
||||||
|
Only repository labels are read; an organization's own labels sit behind a
|
||||||
|
different endpoint and are neither read nor written.
|
||||||
|
|
||||||
|
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the canonical set
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Which taxonomy collection fills which exclusive namespace. Both sides are the
|
||||||
|
# domain's — this dict is only the join between them, and it is the whole
|
||||||
|
# reason no name has to be repeated here.
|
||||||
|
MEMBERS = {"type/": issue.TYPES, "severity/": issue.SEVERITIES}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_names():
|
||||||
|
"""Every name in the canonical set, in taxonomy order.
|
||||||
|
|
||||||
|
Which namespaces are exclusive is issue.EXCLUSIVE_NS; what lives in each
|
||||||
|
is MEMBERS, i.e. the domain again. A namespace the domain declares but
|
||||||
|
MEMBERS does not know about is handed back separately — better reported
|
||||||
|
than quietly missing from the set."""
|
||||||
|
names, orphan = [], []
|
||||||
|
for ns in issue.EXCLUSIVE_NS:
|
||||||
|
if ns in MEMBERS:
|
||||||
|
names += [ns + m for m in MEMBERS[ns]]
|
||||||
|
else:
|
||||||
|
orphan.append(ns)
|
||||||
|
return names, orphan
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# lookalikes
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WORDS = re.compile(r'[^a-z0-9]+')
|
||||||
|
|
||||||
|
|
||||||
|
def akin(name):
|
||||||
|
"""Comparison keys for a label name: its tail, and the whole name squashed.
|
||||||
|
|
||||||
|
Case, separators and the namespace in front are noise — what a person
|
||||||
|
meant is the tail. `x`, `X`, `kind/x` all reduce to the same tail as
|
||||||
|
`type/x`, and `severity: x y` to the same squashed form as `severity/xy`.
|
||||||
|
Two names resemble each other when these sets intersect."""
|
||||||
|
parts = [p for p in WORDS.split(name.lower()) if p]
|
||||||
|
return {parts[-1], "".join(parts)} if parts else set()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# plan
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def color_of(value):
|
||||||
|
"""Gitea reports colors bare, map.py writes them with a `#`. Same color."""
|
||||||
|
return (value or "").lstrip("#").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def drift_of(spec, got):
|
||||||
|
"""Where an existing label disagrees with the spec, as (field, is, want).
|
||||||
|
|
||||||
|
Only color and `exclusive` — a description somebody rewrote is theirs, and
|
||||||
|
the name matched exactly or we would not be here."""
|
||||||
|
out = []
|
||||||
|
if color_of(got.get("color")) != color_of(spec.get("color")):
|
||||||
|
out.append(("color", color_of(got.get("color")), color_of(spec.get("color"))))
|
||||||
|
if bool(got.get("exclusive")) != bool(spec.get("exclusive")):
|
||||||
|
out.append(("exclusive", str(bool(got.get("exclusive"))).lower(),
|
||||||
|
str(bool(spec.get("exclusive"))).lower()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def plan(specs, existing):
|
||||||
|
"""(rows, similar) for one repository, decided before anything is written.
|
||||||
|
|
||||||
|
A row is (name, spec, got, drift), one per canonical label in taxonomy
|
||||||
|
order: `got` is the repository's own payload when that exact name is
|
||||||
|
already there (None when it is not), `drift` what disagrees with the spec.
|
||||||
|
|
||||||
|
`similar` is (name, id, [canonical it resembles]) for the repository's
|
||||||
|
other labels. They are reported and left alone: this script owns the
|
||||||
|
canonical names, not everything that looks like one."""
|
||||||
|
by_name = dict((l.get("name", ""), l) for l in existing or [])
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for name in specs:
|
||||||
|
got = by_name.get(name)
|
||||||
|
rows.append((name, specs[name], got, drift_of(specs[name], got) if got else []))
|
||||||
|
|
||||||
|
keys = dict((name, akin(name)) for name in specs)
|
||||||
|
similar = []
|
||||||
|
for l in existing or []:
|
||||||
|
name = l.get("name", "")
|
||||||
|
if name in specs:
|
||||||
|
continue
|
||||||
|
mine = akin(name)
|
||||||
|
hits = [n for n in specs if keys[n] & mine]
|
||||||
|
if hits:
|
||||||
|
similar.append((name, l.get("id"), hits))
|
||||||
|
return rows, similar
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# run
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Create the canonical type/* and severity/* labels in a repository")
|
||||||
|
ap.add_argument("--dry-run", action="store_true",
|
||||||
|
help="print the plan; not one writing request")
|
||||||
|
ap.add_argument("--fix", action="store_true",
|
||||||
|
help="also patch color/exclusive on labels that already exist")
|
||||||
|
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
names, orphan = canonical_names()
|
||||||
|
for ns in orphan:
|
||||||
|
_gitea.warn("namespace %r is exclusive in the domain but has no members here "
|
||||||
|
"— nothing created for it" % ns)
|
||||||
|
specs = gmap.label_specs(names)
|
||||||
|
|
||||||
|
login = _gitea.require_login()
|
||||||
|
base = _gitea.repo_base(args.repo)
|
||||||
|
|
||||||
|
# Read first, always: the plan is decided against the repository itself,
|
||||||
|
# never against tmp/issues/.labels.json. That cache is what makes
|
||||||
|
# _gitea.ensure_labels cheap for push.py and wrong for a bootstrap — it
|
||||||
|
# answers "what did we create last time", and the answer here has to be
|
||||||
|
# "what does the repository have right now".
|
||||||
|
existing = _gitea.paginate(login, "%s/labels" % base, limit=100)
|
||||||
|
rows, similar = plan(specs, existing)
|
||||||
|
|
||||||
|
fixed, drifted = 0, 0
|
||||||
|
for name, spec, got, drift in rows:
|
||||||
|
mark = " exclusive" if spec.get("exclusive") else ""
|
||||||
|
|
||||||
|
if got is None:
|
||||||
|
if args.dry_run:
|
||||||
|
print("create %-20s %s%s" % (name, spec["color"], mark))
|
||||||
|
continue
|
||||||
|
payload = dict(spec, name=name)
|
||||||
|
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
|
||||||
|
payload_name="label-%s" % name.replace("/", "-"),
|
||||||
|
out_root=issue.ISSUE_ROOT)
|
||||||
|
if not new or "id" not in new:
|
||||||
|
_gitea.die("could not create label %r" % name)
|
||||||
|
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not drift:
|
||||||
|
print("present %-20s id %s" % (name, got.get("id")))
|
||||||
|
continue
|
||||||
|
|
||||||
|
drifted += 1
|
||||||
|
shown = ", ".join("%s %s -> %s" % d for d in drift)
|
||||||
|
if not args.fix:
|
||||||
|
print("present %-20s id %-5s drift: %s" % (name, got.get("id"), shown))
|
||||||
|
continue
|
||||||
|
if args.dry_run:
|
||||||
|
print("fix %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||||
|
continue
|
||||||
|
# Gitea 1.26 patches only the fields it is given, but the unchanged
|
||||||
|
# name and description ride along anyway: they cost nothing and an
|
||||||
|
# older server that reads an absent field as empty would blank them.
|
||||||
|
patch = {"name": name, "description": got.get("description") or ""}
|
||||||
|
for field, _is, _want in drift:
|
||||||
|
patch[field] = spec[field]
|
||||||
|
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
|
||||||
|
payload_name="label-%s" % name.replace("/", "-"),
|
||||||
|
out_root=issue.ISSUE_ROOT)
|
||||||
|
fixed += 1
|
||||||
|
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||||
|
|
||||||
|
for name, id, hits in similar:
|
||||||
|
_gitea.warn("%r (id %s) resembles %s — left alone; rename it by hand or ignore it"
|
||||||
|
% (name, id, ", ".join(hits)))
|
||||||
|
|
||||||
|
missing = sum(1 for r in rows if r[2] is None)
|
||||||
|
print("%d canonical label(s): %d %s, %d present%s%s"
|
||||||
|
% (len(rows), missing, "to create" if args.dry_run else "created",
|
||||||
|
len(rows) - missing,
|
||||||
|
" (%d drifted, %d fixed)" % (drifted, fixed) if drifted else "",
|
||||||
|
", %d similar" % len(similar) if similar else ""))
|
||||||
|
if drifted and not args.fix:
|
||||||
|
print("drift is shown, not applied — re-run with --fix to patch color/exclusive")
|
||||||
|
if args.dry_run:
|
||||||
|
print("dry-run — nothing was written")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
#!/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) body marker `<!-- tea:id … -->`, first line of
|
||||||
|
the tracker-side body; stripped out
|
||||||
|
of the local copy — see below
|
||||||
|
title title verbatim, both ways
|
||||||
|
body body verbatim up, verbatim down except
|
||||||
|
the marker and checkbox state — see
|
||||||
|
with_id_marker / merge_checkbox_state
|
||||||
|
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:
|
||||||
|
— ref extra as branch:; push fills it from git
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
The ONE thing this module does add to a body is the id marker, and it does so
|
||||||
|
because the slug now has to survive a push: `push.py` deletes the local file,
|
||||||
|
so the tracker has to remember what the issue was called here. See
|
||||||
|
`with_id_marker`.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Metadata key for Gitea's `ref` — the branch an issue is pinned to. A sync
|
||||||
|
# field: its value is a git branch name and means exactly `ref`, so the domain
|
||||||
|
# carries it in `extra` and never reads it.
|
||||||
|
BRANCH_KEY = "branch"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the id marker: the slug, kept tracker-side
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# `push.py` deletes the local file once the tracker has confirmed the write, so
|
||||||
|
# the slug — the issue's ONLY identity in the domain — cannot live only on this
|
||||||
|
# machine any more. It rides up in the body as an HTML comment:
|
||||||
|
#
|
||||||
|
# <!-- tea:id wire-sqlc-appclick -->
|
||||||
|
#
|
||||||
|
# Why the body and not `.remote.json`: the map is a local file, and "the local
|
||||||
|
# copy is not the record" is the whole point of deleting it. A marker in the
|
||||||
|
# body survives a rename in the web UI, a lost `.remote.json`, a fresh clone,
|
||||||
|
# and a second machine — none of which the map does. Why an HTML comment: Gitea
|
||||||
|
# renders markdown, so it is invisible to a human reader, and it comes back
|
||||||
|
# verbatim on every API read.
|
||||||
|
#
|
||||||
|
# WHERE: the first line of the tracker-side body, followed by one blank line.
|
||||||
|
# First because it is the one position that does not depend on what sections the
|
||||||
|
# issue happens to have, and because a human who does look at the raw markdown
|
||||||
|
# finds it before the prose rather than buried in it.
|
||||||
|
#
|
||||||
|
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
|
||||||
|
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
|
||||||
|
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
|
||||||
|
# and the slug is already the file's name, so a copy of it in the body would be
|
||||||
|
# duplicated state.
|
||||||
|
#
|
||||||
|
# WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
|
||||||
|
# strip-all-then-prepend-one. `with_id_marker` never appends to what is there,
|
||||||
|
# and `strip_id_marker` removes EVERY marker line, not the first. So a body that
|
||||||
|
# somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on the
|
||||||
|
# next pull and goes back up with exactly one. There is no code path that adds
|
||||||
|
# a marker to a body that has not just been stripped.
|
||||||
|
|
||||||
|
_MARKER_LINE = re.compile(r'^[ \t]*<!--[ \t]*tea:id[ \t]+(\S+)[ \t]*-->[ \t]*$')
|
||||||
|
|
||||||
|
|
||||||
|
def id_marker(id):
|
||||||
|
"""The marker line for a slug. One place formats it, one regex reads it."""
|
||||||
|
return "<!-- tea:id %s -->" % id
|
||||||
|
|
||||||
|
|
||||||
|
def id_in_body(body):
|
||||||
|
"""The slug a tracker-side body claims, or None.
|
||||||
|
|
||||||
|
The FIRST valid marker wins; a second one is ignored here and removed by
|
||||||
|
`strip_id_marker` on the way in. The captured text must be a slug by the
|
||||||
|
domain's own rule — a marker holding anything else is not a slug and is
|
||||||
|
treated as if it were not there, so a mangled comment falls back to the
|
||||||
|
title instead of naming a file after garbage."""
|
||||||
|
for line in (body or "").splitlines():
|
||||||
|
m = _MARKER_LINE.match(line)
|
||||||
|
if m and issue.SLUG_OK.match(m.group(1)):
|
||||||
|
return m.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def strip_id_marker(body):
|
||||||
|
"""`body` with every marker line removed. Idempotent.
|
||||||
|
|
||||||
|
A body that carries no marker is returned byte for byte — the common case
|
||||||
|
(an issue filed in the web UI) costs nothing and is not reformatted. When a
|
||||||
|
marker is removed from the top, the blank line it was written with goes with
|
||||||
|
it, so the round trip is exact: strip(with_id_marker(b, id)) == b."""
|
||||||
|
text = body or ""
|
||||||
|
if not any(_MARKER_LINE.match(l) for l in text.splitlines()):
|
||||||
|
return text
|
||||||
|
kept = [l for l in text.splitlines() if not _MARKER_LINE.match(l)]
|
||||||
|
return "\n".join(kept).lstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def with_id_marker(body, id):
|
||||||
|
"""`body` with exactly one marker, as its first line.
|
||||||
|
|
||||||
|
Strip-then-prepend, always — that is the guarantee that a body can never end
|
||||||
|
up with two, however many it arrived with."""
|
||||||
|
return "%s\n\n%s" % (id_marker(id), strip_id_marker(body))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 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 merge_checkbox_state(remote_body, local_body):
|
||||||
|
"""The remote body with every tick the local copy already had put back.
|
||||||
|
|
||||||
|
The one exception to "a pull overwrites the body", and it is deliberately
|
||||||
|
the narrowest one that works. A tick is **monotone** — an item only ever
|
||||||
|
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
|
||||||
|
reconciled: no base version, no drift tracking, no conflict to resolve. The
|
||||||
|
set is a set of item TEXTS, and an item comes out ticked when either side
|
||||||
|
has it ticked. Everything else in the body is still the remote's word.
|
||||||
|
|
||||||
|
Matching is on `Checkbox.text`, which the domain parser has already
|
||||||
|
stripped and rejoined with single spaces, so rewrapping a long item does
|
||||||
|
not cost it its tick. It is otherwise literal: reword an item and it is a
|
||||||
|
different item — the tick stays with the wording it was put on.
|
||||||
|
|
||||||
|
**The same text more than once** is read as the rule says, as a set: one
|
||||||
|
ticked local item ticks every remote item with that text. The alternative —
|
||||||
|
pairing duplicates up by order — is the reading that can still drop a tick
|
||||||
|
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
|
||||||
|
nothing), and dropping a tick is the bug this exists to fix. Two items
|
||||||
|
whose text is identical are the same item to whoever reads them.
|
||||||
|
|
||||||
|
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
|
||||||
|
pull, an empty store — returns the remote body untouched.
|
||||||
|
|
||||||
|
The price, accepted explicitly: UNticking is not monotone, so a box
|
||||||
|
unticked in the web UI comes back on the next pull. Untick locally, push.
|
||||||
|
"""
|
||||||
|
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
|
||||||
|
if not ticked:
|
||||||
|
return remote_body
|
||||||
|
body = remote_body
|
||||||
|
# set_checkbox trades one character for one character, so line numbers read
|
||||||
|
# off `remote_body` stay valid against the partially rewritten `body`.
|
||||||
|
for c in issue.checkboxes(remote_body):
|
||||||
|
if not c.checked and c.text in ticked:
|
||||||
|
body = issue.set_checkbox(body, c.line, True)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
|
||||||
|
local_body=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.
|
||||||
|
|
||||||
|
`local_body` is the body of the copy already in the store, when there is
|
||||||
|
one. It contributes exactly one thing: its ticked checkboxes survive the
|
||||||
|
overwrite (merge_checkbox_state). Pass None and the remote body is taken
|
||||||
|
whole, which is what a first pull does.
|
||||||
|
|
||||||
|
The id marker is stripped before anything else looks at the body: it is
|
||||||
|
transport bookkeeping, and the caller has already read the slug off it
|
||||||
|
(`pull.id_for`). Everything downstream — checkboxes, `#N` references, what
|
||||||
|
lands on disk — sees the body the author wrote."""
|
||||||
|
body = merge_checkbox_state(
|
||||||
|
strip_id_marker((payload.get("body") or "").strip()), local_body)
|
||||||
|
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("ref"):
|
||||||
|
extra[BRANCH_KEY] = payload["ref"]
|
||||||
|
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 prose is sent verbatim — see the module docstring on why slugs in
|
||||||
|
`## Depends on` are not rewritten to `#N`. The one addition is the id
|
||||||
|
marker, prepended (never appended) so the tracker remembers the slug after
|
||||||
|
push has deleted the local file. `from_api` takes it straight back off, so
|
||||||
|
the body still round-trips byte for byte."""
|
||||||
|
payload = {"title": iss.title,
|
||||||
|
"body": with_id_marker(iss.body.strip(), iss.id)}
|
||||||
|
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
|
||||||
|
# An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
||||||
|
# clear whatever is set on the Gitea side, so the key is left out instead.
|
||||||
|
branch = (iss.extra.get(BRANCH_KEY) or "").strip()
|
||||||
|
if branch:
|
||||||
|
payload["ref"] = branch
|
||||||
|
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,283 @@
|
|||||||
|
#!/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.
|
||||||
|
|
||||||
|
**This is how you get a pushed issue back.** `push.py` deletes the local file
|
||||||
|
once Gitea has confirmed it, so pulling is not a refresh of a copy you kept —
|
||||||
|
it is how the copy comes to exist. It lands under the SAME slug it had before,
|
||||||
|
even after a rename in the web UI and even on a machine that has never seen the
|
||||||
|
issue: the slug travels in the body as `<!-- tea:id … -->`, and
|
||||||
|
tmp/issues/.remote.json indexes it by number. See `id_for` for the order those
|
||||||
|
are consulted in. The marker itself is stripped out of what is written to disk.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
A closed issue is not a unit of work, so filter mode enumerates it but leaves
|
||||||
|
it out of the store: `--state all` still shows the whole picture, and only
|
||||||
|
`--state closed` writes one. The limit is on the write, not on the selection —
|
||||||
|
an issue already on disk is refreshed either way, so the local copy learns it
|
||||||
|
was closed instead of staying open forever, and the count of the ones left out
|
||||||
|
goes to stderr. Key mode is exempt: an address is not a bulk read, and
|
||||||
|
`pull.py 1` fetches a closed issue as it always did.
|
||||||
|
|
||||||
|
Comments ride along by default, in both modes and for every issue written:
|
||||||
|
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
|
||||||
|
nothing when there is nothing to fetch — the payload already carries the
|
||||||
|
comment count, so an issue with none makes no request, and a file left over
|
||||||
|
from an earlier pull is deleted. An absent file therefore means "no comments",
|
||||||
|
never "not asked for". The thread is pull-only: editing it changes nothing in
|
||||||
|
Gitea (post with comment.py).
|
||||||
|
|
||||||
|
Other flags:
|
||||||
|
--deps [--depth N] follow dependencies and pull them too
|
||||||
|
--cached skip issues already on disk (body AND comments)
|
||||||
|
--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 — with exactly one exception, checkbox state. A `[x]`
|
||||||
|
on either side wins for any item whose text matches, because a tick is monotone
|
||||||
|
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
|
||||||
|
has the rule and its price). `--cached` skips an issue before any of that: it is
|
||||||
|
not read and not merged. 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):
|
||||||
|
"""The slug this remote issue belongs under. Three sources, in order.
|
||||||
|
|
||||||
|
1. **`.remote.json`, keyed by number.** The local ledger, and the only one
|
||||||
|
that knows about a file sitting on disk right now, so it wins. A
|
||||||
|
retitled issue keeps the slug it was first pulled under.
|
||||||
|
2. **The `<!-- tea:id … -->` marker in the body** (`gmap.id_in_body`). What
|
||||||
|
makes push -> delete -> pull a round trip rather than a rename: the
|
||||||
|
ledger can be lost (a fresh clone, another machine, a deleted
|
||||||
|
`.remote.json`) and the tracker still remembers what this issue is called
|
||||||
|
here — even after the title was changed in the web UI.
|
||||||
|
3. **The title, slugified.** Issues filed in the web UI have no marker and
|
||||||
|
have never had a local name; this is where they get one.
|
||||||
|
|
||||||
|
A marker is only taken at its word when the slug is free. If a file of that
|
||||||
|
name is already in the store, or the ledger has it under another number, the
|
||||||
|
marker is a collision and not an identity — the name is uniquified
|
||||||
|
(`marked-2`) rather than allowed to overwrite somebody else's issue."""
|
||||||
|
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
marked = gmap.id_in_body(payload.get("body") or "")
|
||||||
|
if marked and marked not in store_ids and marked not in set(remote_map.values()):
|
||||||
|
return marked
|
||||||
|
return issue.unique_id(root, marked or issue.slugify(payload.get("title", "")),
|
||||||
|
taken=store_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def comments_path(root, id):
|
||||||
|
"""Where an issue's comment thread lives — beside it, under the same slug.
|
||||||
|
Named in `_gitea` because push.py has to delete the same file."""
|
||||||
|
return _gitea.comments_path(root, id)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_comments(login, base, root, id, number, count):
|
||||||
|
"""Bring <id>.comments.md in line with the server; return it, or None when
|
||||||
|
the issue has no thread.
|
||||||
|
|
||||||
|
`count` is the payload's own comment count, so an issue with none costs no
|
||||||
|
request. A file from an earlier pull is removed when the thread is empty:
|
||||||
|
the absence of the file is the answer, not a gap in what was asked for."""
|
||||||
|
path = comments_path(root, id)
|
||||||
|
comments = _gitea.get_comments(login, base, number) if count else []
|
||||||
|
if comments:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(gmap.render_comments(comments))
|
||||||
|
return path
|
||||||
|
if os.path.isfile(path):
|
||||||
|
os.remove(path) # stale thread from an earlier pull
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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("--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: <repo>/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
|
||||||
|
# A first pull into a fresh checkout has to create the store; it says so,
|
||||||
|
# and the path is absolute, so it cannot be a stray cwd.
|
||||||
|
if issue.create_store(root):
|
||||||
|
sys.stderr.write("created store %s\n" % os.path.abspath(root))
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
# A closed issue is not a unit of work: filter mode enumerates it but keeps
|
||||||
|
# it out of the store unless the operator named the state. A key is an
|
||||||
|
# address, not a bulk read, so key mode is exempt.
|
||||||
|
drop_closed = filtered and args.state != "closed"
|
||||||
|
|
||||||
|
written, skipped, dropped, 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)
|
||||||
|
|
||||||
|
# ---- walk ------------------------------------------------------------
|
||||||
|
while queue:
|
||||||
|
payload, depth = queue.pop(0)
|
||||||
|
number = payload["number"]
|
||||||
|
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||||
|
stored = os.path.isfile(issue.path_of(root, id))
|
||||||
|
|
||||||
|
# Closed and not already ours: nothing is written and nothing is asked
|
||||||
|
# of the server for it, not even its comments. The slug stays unclaimed
|
||||||
|
# too, so no other issue ends up pointing `depends:` at a missing file.
|
||||||
|
if drop_closed and payload.get("state") == "closed" and not stored:
|
||||||
|
dropped.append(number)
|
||||||
|
else:
|
||||||
|
store_ids.add(id)
|
||||||
|
number_of_id[number] = id
|
||||||
|
if args.cached and stored:
|
||||||
|
skipped.append(id) # untouched, unread, and not one request spent
|
||||||
|
else:
|
||||||
|
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
||||||
|
# The copy already on disk, as it was when this run started. It
|
||||||
|
# contributes its ticked checkboxes and nothing else; None when
|
||||||
|
# the store has never seen this issue.
|
||||||
|
prev = issues.get(id)
|
||||||
|
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||||
|
id_for_number=number_of_id,
|
||||||
|
extra_numbers=extra,
|
||||||
|
synced=_gitea.now_iso(),
|
||||||
|
local_body=prev.body if prev else None)
|
||||||
|
issue.save(root, iss)
|
||||||
|
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
||||||
|
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))
|
||||||
|
|
||||||
|
# Nothing is dropped in silence — say how many closed ones stayed out.
|
||||||
|
if dropped:
|
||||||
|
sys.stderr.write("%d closed issue(s) enumerated, not stored"
|
||||||
|
" (--state closed to pull them)\n" % len(dropped))
|
||||||
|
|
||||||
|
# ---- 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)
|
||||||
|
|
||||||
|
_gitea.save_map(root, remote_map)
|
||||||
|
index_path, _ = issue_index.build(root)
|
||||||
|
|
||||||
|
# Compact output — the only thing that lands in the model's context. The
|
||||||
|
# thread rides on the issue's own line; no file means no comments.
|
||||||
|
for id in sorted(set(written) | set(skipped)):
|
||||||
|
iss = issue.load(root, id)
|
||||||
|
note = " (cached)" if id in skipped else ""
|
||||||
|
cpath = comments_path(root, id)
|
||||||
|
if os.path.isfile(cpath):
|
||||||
|
note += " +%s comments: %s" % (iss.extra.get("comments") or "?", cpath)
|
||||||
|
print("%s [%s] %s — %s %s%s" % (
|
||||||
|
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
||||||
|
issue.path_of(root, id), note))
|
||||||
|
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,417 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
push.py — local store -> Gitea, and the local copy goes away.
|
||||||
|
|
||||||
|
**A successful push deletes `tmp/issues/<id>.md` and `<id>.comments.md`.** Once
|
||||||
|
the tracker has the issue, the tracker IS the issue: what is left in the store
|
||||||
|
is only what has not left this machine. Get it back with `pull.py <n>` — it
|
||||||
|
comes back under the same slug, because the slug travelled up in the body as
|
||||||
|
`<!-- tea:id … -->` (map.with_id_marker) and is also recorded in
|
||||||
|
`.remote.json`. That is the reversal of "pushing is additive, the file is never
|
||||||
|
deleted"; it is deliberate, and AGENTS.md and references/format.md say so too.
|
||||||
|
|
||||||
|
ONE RULE, NO EXCEPTION: `--update` deletes as well. A PATCH is a push, and an
|
||||||
|
issue that has just been sent is no more local than one that was just created.
|
||||||
|
Two rules would put back exactly the question this removes — "is my copy the
|
||||||
|
fresh one?".
|
||||||
|
|
||||||
|
The deletion is the LAST thing that happens to an issue, and only after:
|
||||||
|
|
||||||
|
1. the api call returned (it did not raise, and `tea` exited 0), and
|
||||||
|
2. the answer is a dict carrying a plausible `number`, and on `--update`
|
||||||
|
the very number that was PATCHed (`confirmed_number`), and
|
||||||
|
3. `.remote.json` has been written with number -> slug.
|
||||||
|
|
||||||
|
Network down, non-2xx, a body that does not confirm the write, a mismatched
|
||||||
|
number: the file stays and the run stops. Nothing here removes a file it has not
|
||||||
|
just watched Gitea accept, and nothing removes a file for an issue it did not
|
||||||
|
send — `origin: local` work that has never been pushed is never touched.
|
||||||
|
|
||||||
|
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, nothing deleted
|
||||||
|
|
||||||
|
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 tracker shows no edge for it.
|
||||||
|
|
||||||
|
The graph goes up with them. Once an issue has its number, every `depends:`
|
||||||
|
entry that also has one becomes a **native Gitea link** — the same
|
||||||
|
`/dependencies` that `pull.py --deps` reads back, so the tracker shows the
|
||||||
|
blocking panel and refuses to close a blocked issue first. Topological order
|
||||||
|
means the blocker already has its number by then; no second pass is needed.
|
||||||
|
`--update` links whatever appeared in `depends:` since the last push. A link
|
||||||
|
the tracker already has is skipped, not re-POSTed. A dependency that stayed
|
||||||
|
local has no number and becomes no link — only the warning above.
|
||||||
|
|
||||||
|
REMOVING a link is OUT OF SCOPE. Push only ever adds: a dependency deleted
|
||||||
|
from `depends:` leaves its Gitea link standing, and nothing here will notice.
|
||||||
|
Unlink it in the web UI, or by hand with
|
||||||
|
`tea api -X DELETE --login "$GITEA_LOGIN" repos/OWNER/REPO/issues/N/dependencies`.
|
||||||
|
|
||||||
|
The `## Depends on` prose itself is never touched — slugs stay slugs and are
|
||||||
|
not rewritten to `#N`, so the body survives a pull -> push round trip byte for
|
||||||
|
byte. The link lives in Gitea's own graph, not in the text.
|
||||||
|
|
||||||
|
Missing labels are created with the canonical color and, for type/* and
|
||||||
|
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
||||||
|
|
||||||
|
`branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is
|
||||||
|
filled with the current git branch and goes up with the issue; one that is
|
||||||
|
already set is sent as written and never overwritten. Detached HEAD, or no repo
|
||||||
|
at all: no `ref` is sent and a warning says so. It is not written back to the
|
||||||
|
file any more — there is no file to write it back to; it comes down with the
|
||||||
|
next pull.
|
||||||
|
|
||||||
|
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
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 ledger_keys(remote_map, repo=None):
|
||||||
|
"""slug -> remote key, the reverse of `.remote.json`.
|
||||||
|
|
||||||
|
Where a dependency's number comes from once push has deleted its file. The
|
||||||
|
forward map is keyed by number because that is what a pull has in hand; a
|
||||||
|
push has a slug, so it needs the other direction. Same-repo entries win if a
|
||||||
|
slug somehow appears under two keys."""
|
||||||
|
out = {}
|
||||||
|
for key, slug in sorted(remote_map.items()):
|
||||||
|
if slug not in out or gmap.parse_remote_key(key)[0] == repo:
|
||||||
|
out[slug] = key
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dep_state(iss, issues, pushing, key_of_id=None):
|
||||||
|
"""What each `depends:` entry is, as far as linking is concerned.
|
||||||
|
|
||||||
|
Yields (slug, remote_key, in_run) per dependency this run can say anything
|
||||||
|
about:
|
||||||
|
|
||||||
|
remote_key where the dependency lives in Gitea, or None while it is
|
||||||
|
local-only
|
||||||
|
in_run this push is about to give it one
|
||||||
|
|
||||||
|
A dependency's key is read from its `gitea:` field when the file is still
|
||||||
|
on disk, and from the ledger (`key_of_id`) when it is not — which, since
|
||||||
|
push deletes what it sends, is the normal state of an already-published
|
||||||
|
blocker. Without that fallback the graph would quietly lose an edge every
|
||||||
|
time a blocker was pushed before its dependent: the file is gone, the field
|
||||||
|
goes with it, and the link is never made.
|
||||||
|
|
||||||
|
A slug that is neither in the store nor in the ledger is dropped; it names
|
||||||
|
nothing this machine has ever seen, and validate() has already warned.
|
||||||
|
|
||||||
|
In the real run remote_key is all that matters — topological order means an
|
||||||
|
in-run blocker has already been stamped by the time its dependent is sent.
|
||||||
|
`--dry-run` has no numbers to stamp, so it leans on in_run to say which
|
||||||
|
links are coming and which cannot exist at all."""
|
||||||
|
key_of_id = key_of_id or {}
|
||||||
|
out = []
|
||||||
|
for d in iss.depends:
|
||||||
|
dep = issues.get(d)
|
||||||
|
key = (dep.extra.get("gitea") if dep is not None else None) or key_of_id.get(d)
|
||||||
|
if dep is None and not key:
|
||||||
|
continue
|
||||||
|
out.append((d, key or None, d in pushing))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def confirmed_number(got, sent_number=None):
|
||||||
|
"""The number Gitea confirmed for a write, or None — the deletion gate.
|
||||||
|
|
||||||
|
Every local file this script removes is removed because this function
|
||||||
|
returned an int, so it is written to be boring and to say no by default.
|
||||||
|
An answer counts only when it is a dict carrying a positive integer
|
||||||
|
`number`, and, when `sent_number` is given (a PATCH, where we already know
|
||||||
|
which issue we addressed), the same number we sent.
|
||||||
|
|
||||||
|
`bool` is rejected explicitly: `True` is an `int` in Python and `number:
|
||||||
|
true` is not a confirmation of anything.
|
||||||
|
|
||||||
|
What this does NOT have to catch, because it never gets here: a non-2xx
|
||||||
|
answer or a `tea` that failed to run at all — `_gitea.api` exits on both,
|
||||||
|
and an exception in the transport propagates. The file survives all three
|
||||||
|
by never reaching the delete."""
|
||||||
|
if not isinstance(got, dict):
|
||||||
|
return None
|
||||||
|
n = got.get("number")
|
||||||
|
if isinstance(n, bool) or not isinstance(n, int) or n <= 0:
|
||||||
|
return None
|
||||||
|
if sent_number is not None and n != sent_number:
|
||||||
|
return None
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def drop_local(root, id):
|
||||||
|
"""Delete the local copy of an issue and its thread; return what went.
|
||||||
|
|
||||||
|
Deliberately dumb: it takes an id, not a decision. Whether an issue may be
|
||||||
|
dropped is decided by the caller, before this is reached, so the dangerous
|
||||||
|
half of the operation has no branches in it at all. There is exactly one
|
||||||
|
call site.
|
||||||
|
|
||||||
|
A missing file is not an error — an issue with no comments has no thread."""
|
||||||
|
gone = []
|
||||||
|
for p in (issue.path_of(root, id), _gitea.comments_path(root, id)):
|
||||||
|
if os.path.isfile(p):
|
||||||
|
os.remove(p)
|
||||||
|
gone.append(p)
|
||||||
|
return gone
|
||||||
|
|
||||||
|
|
||||||
|
def git_branch():
|
||||||
|
"""The branch HEAD is on, or None. The only git call these scripts make —
|
||||||
|
read, never write. A detached HEAD prints `HEAD` and outside a repo git
|
||||||
|
exits non-zero; both mean "no branch to name", which is not an error."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
name = r.stdout.strip()
|
||||||
|
if r.returncode != 0 or not name or name == "HEAD":
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
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: <repo>/tmp/issues)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
root = args.out
|
||||||
|
problem = issue.store_error(root)
|
||||||
|
if problem:
|
||||||
|
_gitea.die("%s — create an issue with issue_new.py first" % problem)
|
||||||
|
issues = issue.load_all(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))
|
||||||
|
|
||||||
|
# ---- branch: -> Gitea `ref` ------------------------------------------
|
||||||
|
# Only an empty field is filled: a branch written by hand is the author's
|
||||||
|
# decision and push does not argue with it. Nothing to read (detached HEAD,
|
||||||
|
# no repo) is not an error — the issue goes up without a `ref`. The value is
|
||||||
|
# set on the in-memory issue only; the file it came from is about to be
|
||||||
|
# deleted, and the branch comes back with the next pull.
|
||||||
|
blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)]
|
||||||
|
branch = git_branch() if blank else None
|
||||||
|
if branch:
|
||||||
|
for id in blank:
|
||||||
|
issues[id].extra[gmap.BRANCH_KEY] = branch
|
||||||
|
elif blank:
|
||||||
|
_gitea.warn("no current git branch (detached HEAD, or outside a git repo) "
|
||||||
|
"— no `ref` on: %s" % ", ".join(blank))
|
||||||
|
|
||||||
|
pushing = set(order)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
links = 0
|
||||||
|
# The ledger costs no request, so a dry run resolves an already-pushed
|
||||||
|
# blocker the same way the real run does.
|
||||||
|
key_of_id = ledger_keys(_gitea.load_map(root), args.repo)
|
||||||
|
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"))
|
||||||
|
# Not one request is made here: everything below is read off the
|
||||||
|
# store. `#?` is a number this run has not handed out yet.
|
||||||
|
for slug, key, in_run in dep_state(iss, issues, pushing, key_of_id):
|
||||||
|
if key:
|
||||||
|
print(" link -> %s (%s)" % (key, slug))
|
||||||
|
links += 1
|
||||||
|
elif in_run:
|
||||||
|
print(" link -> #? (%s, created by this run)" % slug)
|
||||||
|
links += 1
|
||||||
|
else:
|
||||||
|
print(" no link: %s is local-only" % slug)
|
||||||
|
print("%d issue(s) would be %s, %d dependency link(s) would be created"
|
||||||
|
% (len(order), "updated" if args.update else "created", links))
|
||||||
|
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)
|
||||||
|
key_of_id = ledger_keys(remote_map, repo)
|
||||||
|
|
||||||
|
for id in order:
|
||||||
|
iss = issues[id]
|
||||||
|
|
||||||
|
# Local-only means "this machine has never sent it": no `gitea:` on the
|
||||||
|
# file AND no entry in the ledger. A blocker whose file push already
|
||||||
|
# dropped is in the ledger and is not one of these.
|
||||||
|
unsynced = [d for d in iss.depends
|
||||||
|
if d in issues and not issues[d].extra.get("gitea")
|
||||||
|
and d not in key_of_id and d not in pushing]
|
||||||
|
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))
|
||||||
|
|
||||||
|
sent_number = gmap.number_of(iss)
|
||||||
|
if sent_number:
|
||||||
|
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
||||||
|
got = _gitea.api(login, "%s/issues/%d" % (base, sent_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"
|
||||||
|
# The gate. Below this line the local file is going to be deleted, so
|
||||||
|
# anything short of a confirmed write has to stop the run here.
|
||||||
|
number = confirmed_number(got, sent_number)
|
||||||
|
if number is None:
|
||||||
|
_gitea.die("%s: %s failed — the tracker's answer does not confirm the "
|
||||||
|
"write (%.200r). %s is untouched."
|
||||||
|
% (id, verb, got, issue.path_of(root, id)))
|
||||||
|
|
||||||
|
# The number is confirmed, so the ledger learns it now — before the
|
||||||
|
# label fix-up below, which can still fail, and well before the file is
|
||||||
|
# removed. `.remote.json` is what a later `pull.py N` uses to land on
|
||||||
|
# this slug again; an interrupted run must cost a re-pull, not a slug.
|
||||||
|
remote_map[gmap.remote_key(repo, number)] = id
|
||||||
|
key_of_id[id] = gmap.remote_key(repo, number)
|
||||||
|
_gitea.save_map(root, remote_map)
|
||||||
|
|
||||||
|
# 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)))
|
||||||
|
|
||||||
|
# The in-memory issue is stamped even though its file is going: the rest
|
||||||
|
# of this loop reads `gitea:` off it to link dependencies, and a later
|
||||||
|
# issue in topological order asks the same of this one.
|
||||||
|
gmap.apply_remote(iss, got, repo, _gitea.now_iso())
|
||||||
|
|
||||||
|
# Where the issue lives now. The number and the URL lead because this
|
||||||
|
# is the receipt: in a moment the local path is gone and this is the
|
||||||
|
# only address the issue has.
|
||||||
|
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
|
||||||
|
|
||||||
|
# ---- the graph, as Gitea's own links ------------------------------
|
||||||
|
# Blockers came first in topological order, so each one that is going
|
||||||
|
# to have a number has one already — stamped on the in-memory issue
|
||||||
|
# above, or read out of the ledger for one whose file an earlier push
|
||||||
|
# already dropped. The GET is the idempotence check: it costs one
|
||||||
|
# request per issue that has dependencies at all, and it is what makes
|
||||||
|
# a repeat push a no-op.
|
||||||
|
wanted_links = [(slug, gmap.parse_remote_key(key))
|
||||||
|
for slug, key, _ in dep_state(iss, issues, pushing, key_of_id)
|
||||||
|
if key]
|
||||||
|
if wanted_links:
|
||||||
|
have = _gitea.native_dep_pairs(login, base, number)
|
||||||
|
for slug, (drepo, dnum) in wanted_links:
|
||||||
|
if not dnum or (drepo, dnum) in have:
|
||||||
|
continue
|
||||||
|
if _gitea.add_dependency(login, base, number, drepo, dnum, root):
|
||||||
|
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
|
||||||
|
else:
|
||||||
|
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
|
||||||
|
"hand, or `pull.py %d` and push it again"
|
||||||
|
% (id, number, drepo, dnum, slug, number))
|
||||||
|
|
||||||
|
# ---- and now the local copy goes ----------------------------------
|
||||||
|
# The last thing that happens to this issue, after the write, the
|
||||||
|
# ledger, and the links. A failure above is a warning and lands here
|
||||||
|
# anyway: the issue IS in Gitea, so keeping a stale file beside it
|
||||||
|
# would put back exactly the two-copies question this removes.
|
||||||
|
for p in drop_local(root, id):
|
||||||
|
print(" dropped %s" % p)
|
||||||
|
print(" pull.py %d to work on it again" % number)
|
||||||
|
|
||||||
|
_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,69 @@
|
|||||||
|
#!/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: <repo>/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,245 +0,0 @@
|
|||||||
# Issue format
|
|
||||||
|
|
||||||
Canonical format for every issue created or edited via `tea`. Designed to be
|
|
||||||
unambiguous for both humans and LLMs: fixed English section headers in a fixed
|
|
||||||
order, verifiable acceptance criteria, one issue = one deliverable. Source
|
|
||||||
spec: the project wiki ([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
|
|
||||||
|
|
||||||
## Language rules
|
|
||||||
|
|
||||||
- **Issue title**: English, imperative mood, no type prefix — the type lives in
|
|
||||||
the label, not the title. Good: `Fix tea-guard crash on empty settings file`.
|
|
||||||
Bad: `fix: crash`, `[bug] crash`, `Крашится гвард`.
|
|
||||||
- **Section headers**: the exact English literals below, as `##` headings, in
|
|
||||||
the given order. Do not translate, rename, or reorder them.
|
|
||||||
- **Body prose** (text inside sections): Russian.
|
|
||||||
|
|
||||||
## Label namespaces
|
|
||||||
|
|
||||||
Four namespaces classify an issue. Two are exclusive (Gitea enforces at most
|
|
||||||
one label from the scope), two are free-form:
|
|
||||||
|
|
||||||
| Namespace | Exclusive | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `type/*` | yes | What kind of work; primarily its business value. Mandatory, exactly one. |
|
|
||||||
| `severity/*` | yes | Business impact. At most one; apply when the impact is known. |
|
|
||||||
| `tech/*` | no | Technology the issue is bound to. Any number. |
|
|
||||||
| `comp/*` | no | System component of this repo. Any number; no preset — project-specific. |
|
|
||||||
|
|
||||||
### `type/*` — mandatory, exactly one
|
|
||||||
|
|
||||||
| Label | Color | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `type/bug` | `#ee0701` | Something behaves incorrectly in existing code |
|
|
||||||
| `type/task` | `#0e8a16` | Implementation of new functionality |
|
|
||||||
| `type/refactor` | `#1d76db` | Internal restructuring: file moves, architecture; behavior must not change |
|
|
||||||
| `type/test` | `#fbca04` | Writing or fixing tests |
|
|
||||||
| `type/feature` | `#5319e7` | Container: several issues delivering one unit of business value |
|
|
||||||
| `type/draft` | `#cccccc` | Idea captured for later; not ready for work |
|
|
||||||
|
|
||||||
### `severity/*` — at most one
|
|
||||||
|
|
||||||
| Label | Color |
|
|
||||||
|---|---|
|
|
||||||
| `severity/low` | `#c2e0c6` |
|
|
||||||
| `severity/medium` | `#fbca04` |
|
|
||||||
| `severity/high` | `#eb6420` |
|
|
||||||
| `severity/showstopper` | `#ee0701` |
|
|
||||||
| `severity/critical` | `#b60205` |
|
|
||||||
|
|
||||||
### `tech/*` — any number
|
|
||||||
|
|
||||||
Technology-bound labels, e.g. `tech/sql` (pgx, sqlc, sql-migrate — persistent
|
|
||||||
storage), `tech/obs` (grafana, loki, prometheus, alloy — observability),
|
|
||||||
`tech/postgres`.
|
|
||||||
|
|
||||||
### `comp/*` — any number
|
|
||||||
|
|
||||||
Components of this repo's system, e.g. `comp/appclick`. No preset list —
|
|
||||||
derive from the project.
|
|
||||||
|
|
||||||
### Creating exclusive labels
|
|
||||||
|
|
||||||
Gitea enforces exclusivity only if the label was created with
|
|
||||||
`exclusive: true`. The `tea labels create` command (as of tea 0.14.2) cannot
|
|
||||||
set that field, so missing `type/*` and `severity/*` labels MUST be created
|
|
||||||
via `tea api`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tea api --login "$GITEA_LOGIN" -X POST \
|
|
||||||
-d '{"name":"type/bug","color":"#ee0701","exclusive":true,"description":"Something behaves incorrectly in existing code"}' \
|
|
||||||
repos/{owner}/{repo}/labels
|
|
||||||
```
|
|
||||||
|
|
||||||
`tech/*` and `comp/*` are non-exclusive; either `tea labels create` or
|
|
||||||
`tea api` works for them.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
An issue may explicitly depend on others. Declare that in an optional
|
|
||||||
`## Depends on` section placed right after `## Spec`, one `#N` reference per
|
|
||||||
line:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Depends on
|
|
||||||
- #12 — нужна схема БД из этого issue
|
|
||||||
- #15
|
|
||||||
```
|
|
||||||
|
|
||||||
Omit the section when there are no dependencies — never write an empty one.
|
|
||||||
|
|
||||||
## Shared rules
|
|
||||||
|
|
||||||
- `## Summary` is always the first section; `## Acceptance criteria` is always
|
|
||||||
present (exception: `type/draft`). These two are the anchors every reader
|
|
||||||
(human or LLM) relies on.
|
|
||||||
- `## Spec` is **mandatory in every type**. Its value is a repo path
|
|
||||||
(`docs/specs/auth.md`), a URL, or the literal `none` when no spec exists.
|
|
||||||
Never omit the section and never invent a link — `none` is an explicit,
|
|
||||||
valid answer.
|
|
||||||
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
|
|
||||||
checkable condition, not an aspiration.
|
|
||||||
- Code references use the `path/file.ext:line` form; related issues as `#N`.
|
|
||||||
- Screenshots are allowed but their content must be duplicated as text — an
|
|
||||||
LLM posting through `tea api` cannot read images.
|
|
||||||
- If acceptance criteria grow past ~5 unrelated items, split the issue (or
|
|
||||||
promote it to a `type/feature` container with child issues).
|
|
||||||
|
|
||||||
## Template: `type/bug`
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Summary
|
|
||||||
Что сломано и где проявляется, одно-два предложения.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
`docs/specs/auth.md`, URL — или `none`.
|
|
||||||
|
|
||||||
## Steps to reproduce
|
|
||||||
1. …
|
|
||||||
2. …
|
|
||||||
|
|
||||||
## Expected
|
|
||||||
Что должно было произойти.
|
|
||||||
|
|
||||||
## Actual
|
|
||||||
Что происходит на самом деле: вывод команды, лог.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
Только релевантное: версии, ОС, конфигурация.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] баг не воспроизводится по шагам выше
|
|
||||||
- [ ] добавлена проверка на регрессию (если применимо)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Template: `type/task`
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Summary
|
|
||||||
Что нужно сделать, одно-два предложения.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
Ссылка или `none`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
Какую проблему пользователя/системы это решает.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] проверяемое условие
|
|
||||||
- [ ] …
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
Что НЕ входит в объём; технические рамки. (опционально)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Template: `type/refactor`
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Summary
|
|
||||||
Что перестраиваем и в каких файлах (`path/file:line`).
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
Ссылка или `none`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Template: `type/test`
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Summary
|
|
||||||
Что покрываем тестами и где (`path/file:line`).
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
Ссылка или `none`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
|
||||||
|
|
||||||
## Test cases
|
|
||||||
- сценарий → ожидаемый результат
|
|
||||||
- …
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] перечисленные кейсы покрыты и зелёные
|
|
||||||
- [ ] тесты проходят в CI
|
|
||||||
```
|
|
||||||
|
|
||||||
## Template: `type/feature`
|
|
||||||
|
|
||||||
A container: one unit of business value delivered by several child issues.
|
|
||||||
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and link
|
|
||||||
back via `## Depends on` or the `## Issues` list here. Keep implementation
|
|
||||||
detail in the children; the feature body stays at business level.
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Summary
|
|
||||||
Бизнес-ценность одним-двумя предложениями.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
Ссылка или `none`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
Какую проблему пользователя/системы это решает.
|
|
||||||
|
|
||||||
## Issues
|
|
||||||
- [ ] #N — краткое описание части
|
|
||||||
- [ ] …
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- [ ] все дочерние issues закрыты
|
|
||||||
- [ ] проверяемое условие уровня фичи (например, e2e-сценарий работает)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Template: `type/draft`
|
|
||||||
|
|
||||||
A parking spot for ideas that are not fleshed out yet. Minimal structure, no
|
|
||||||
acceptance criteria required. Before implementation starts, a draft MUST be
|
|
||||||
promoted: relabeled to a concrete type and rewritten into that type's
|
|
||||||
template.
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Summary
|
|
||||||
Идея одним-двумя предложениями.
|
|
||||||
|
|
||||||
## Spec
|
|
||||||
Ссылка или `none` (для драфтов обычно `none`).
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
Свободные заметки: что известно, открытые вопросы, варианты.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Containers beyond `type/feature`
|
|
||||||
|
|
||||||
- **Milestone** — a set of issues with an optional time bound. Manage via
|
|
||||||
`tea milestones` / `tea milestones issues`.
|
|
||||||
- **Project** — a set of issues describing one project, tracked by status
|
|
||||||
columns. Standard statuses: Backlog, ToDo, InProgress, Ready, Done. The
|
|
||||||
Gitea projects API is not exposed via `tea` subcommands — use the web UI.
|
|
||||||
@@ -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()
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: wiki
|
||||||
|
description: Move wiki pages between a local space and Gitea — fetch a page and everything under it as a local cache, publish a page tree with an update message, list what the wiki holds. Load when the user asks to read/fetch a wiki page, cache a wiki subtree for a discussion, or publish artifacts to the wiki. Organizing artifacts into a page tree (titles, ordering, the index) is /tea:page and needs no network.
|
||||||
|
---
|
||||||
|
|
||||||
|
# /tea:wiki — the bridge between a local space and a Gitea wiki
|
||||||
|
|
||||||
|
One job: translate between `tmp/wiki/<space>/` and Gitea's wiki JSON, and carry
|
||||||
|
the result over the wire. Everything about **what a page tree is** — titles,
|
||||||
|
ordering, paths, the index — belongs to `/tea:page` and is imported from there,
|
||||||
|
never redefined here.
|
||||||
|
|
||||||
|
Transport is `tea api` through `skills/sync/scripts/_gitea.py`: the same login
|
||||||
|
pin, the same pagination, the same payload files. There is no second transport.
|
||||||
|
|
||||||
|
## The wiki is flat, and that is the whole design
|
||||||
|
|
||||||
|
Gitea's wiki is a list of pages, not a tree. Nesting exists only inside a
|
||||||
|
title, as `/`, and Gitea escapes that title into a filename by rules that are
|
||||||
|
its own:
|
||||||
|
|
||||||
|
| title | `sub_url` |
|
||||||
|
|---|---|
|
||||||
|
| `Abstract Issue` | `Abstract-Issue` |
|
||||||
|
| `zz-probe/child` | `zz-probe%2Fchild.-` |
|
||||||
|
| `Simple Chains/Parked/Chain decisions — DC` | `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC` |
|
||||||
|
|
||||||
|
**`sub_url` is the identity and is never constructed.** It is read back from
|
||||||
|
the API and stored in the manifest. Building one by hand that is almost right
|
||||||
|
does not fail loudly — it creates a second page and abandons the first.
|
||||||
|
|
||||||
|
**Never commit a subdirectory into the wiki's git repository.** A real
|
||||||
|
`folder/page.md` is invisible to the API and to the web UI. It is a ghost file.
|
||||||
|
Do not clone the wiki repo to work in; use these scripts.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
In `<skill-base-dir>/scripts/`.
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `wiki_ls.py [--prefix T]` | what the wiki actually holds — titles, `sub_url`, last commit. One call, no bodies |
|
||||||
|
| `wiki_pull.py [--prefix T] [--space S]` | fetch a page and everything under it into a local space |
|
||||||
|
| `wiki_push.py -m MSG [--prefix T] [PATH…]` | publish; create what is new, update what changed, skip what is not |
|
||||||
|
| `wikimap.py` | md ↔ wiki JSON, pure — not a command |
|
||||||
|
|
||||||
|
## Fetching a subtree as a cache
|
||||||
|
|
||||||
|
"A page and its children" is a prefix test on the title, run against one
|
||||||
|
listing call, followed by one GET per page. There is no tree endpoint and no
|
||||||
|
bulk-body endpoint.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/wiki_ls.py --prefix "Simple Chains" # what is there
|
||||||
|
python3 scripts/wiki_pull.py --prefix "Simple Chains" # cache it locally
|
||||||
|
```
|
||||||
|
|
||||||
|
A pull **overwrites** the local body — a fetch, not a merge. `synced` tells you
|
||||||
|
how old your copy is; re-pull when it matters. Nothing tracks drift.
|
||||||
|
|
||||||
|
The space defaults to the repo's own `owner/repo`, so a pull with no flags
|
||||||
|
caches this repo's whole wiki into `tmp/wiki/<owner>/<repo>/`.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/wiki_push.py -m "Import the simple-chains discussion" --dry-run
|
||||||
|
python3 scripts/wiki_push.py -m "Import the simple-chains discussion"
|
||||||
|
```
|
||||||
|
|
||||||
|
`-m` is required and is the wiki commit message — the only record of why a page
|
||||||
|
changed, and it shows up in `wiki/revisions/<sub_url>`. One operation, one
|
||||||
|
message.
|
||||||
|
|
||||||
|
Change detection is a hash: a page whose file matches `pushed` is skipped.
|
||||||
|
A page with no `sub_url` is created; one with a `sub_url` is edited in place,
|
||||||
|
using the title **from the manifest** — sending a different title to the edit
|
||||||
|
endpoint is a rename and leaves nothing at the old address.
|
||||||
|
|
||||||
|
Selection, narrowest first: positional `PATH`-or-`TITLE` arguments (matched
|
||||||
|
exactly), then `--prefix`, then the whole space.
|
||||||
|
|
||||||
|
**Pushing is additive.** A page deleted locally is not deleted in the wiki.
|
||||||
|
Removing a published page is an explicit act — the web UI, or
|
||||||
|
`tea api --login "$GITEA_LOGIN" -X DELETE repos/{owner}/{repo}/wiki/page/<sub_url>`.
|
||||||
|
|
||||||
|
## Order of operations for a fresh tree
|
||||||
|
|
||||||
|
The index links published pages by `sub_url`, which does not exist until the
|
||||||
|
first push. So:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ../page/scripts/page_import.py --from DIR --prefix "Simple Chains"
|
||||||
|
python3 scripts/wiki_push.py -m "Import the simple-chains discussion"
|
||||||
|
python3 ../page/scripts/page_index.py --prefix "Simple Chains" # now with real links
|
||||||
|
python3 scripts/wiki_push.py -m "Index"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linking an issue to a page
|
||||||
|
|
||||||
|
An issue's `wiki:` field holds page **titles**, not URLs — a title is a name for
|
||||||
|
a document and stays in the domain; the URL is bookkeeping. `wiki_ls.py
|
||||||
|
--titles` prints them one per line, which is what to paste.
|
||||||
|
|
||||||
|
## Endpoints, for when a script is not enough
|
||||||
|
|
||||||
|
Reach for `/tea:use` and `tea api` directly only for what has no script — a
|
||||||
|
delete, or a page's history.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| list | `GET repos/{owner}/{repo}/wiki/pages` |
|
||||||
|
| read | `GET repos/{owner}/{repo}/wiki/page/{sub_url}` |
|
||||||
|
| create | `POST repos/{owner}/{repo}/wiki/new` — `{title, content_base64, message}` |
|
||||||
|
| edit | `PATCH repos/{owner}/{repo}/wiki/page/{sub_url}` — same body |
|
||||||
|
| delete | `DELETE repos/{owner}/{repo}/wiki/page/{sub_url}` |
|
||||||
|
| history | `GET repos/{owner}/{repo}/wiki/revisions/{sub_url}` |
|
||||||
|
|
||||||
|
The `tea` CLI has no wiki subcommand. `tea api` is the only route.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
wiki_ls.py — list what is actually in a wiki. One call, no bodies.
|
||||||
|
|
||||||
|
Cheap enough to run before a pull: it tells you what titles exist, which is the
|
||||||
|
only thing a prefix filter can be built from, and it shows the `sub_url` Gitea
|
||||||
|
settled on for each — worth a look the first time a title contains a dash or a
|
||||||
|
slash, because the escaping is not what anyone guesses.
|
||||||
|
|
||||||
|
wiki_ls.py
|
||||||
|
wiki_ls.py --prefix "Simple Chains"
|
||||||
|
wiki_ls.py --repo other/repo --titles
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
wiki_ls.py [--repo owner/repo] [--prefix TITLE] [--titles] [--urls]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, _HERE)
|
||||||
|
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
|
||||||
|
import _gitea # noqa: E402
|
||||||
|
import wikimap # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def cell(v):
|
||||||
|
return (str(v or "").strip().replace("|", "\\|")) or "—"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
|
||||||
|
ap.add_argument("--prefix", default="", help="only titles at or under this")
|
||||||
|
ap.add_argument("--titles", action="store_true",
|
||||||
|
help="print one title per line and nothing else")
|
||||||
|
ap.add_argument("--urls", action="store_true", help="add the browser URL")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
login = _gitea.require_login()
|
||||||
|
base = _gitea.repo_base(a.repo)
|
||||||
|
slug = _gitea.repo_slug(login, a.repo)
|
||||||
|
|
||||||
|
listing = _gitea.paginate(login, "%s/wiki/pages" % base)
|
||||||
|
if not isinstance(listing, list):
|
||||||
|
_gitea.die("unexpected listing from %s/wiki/pages" % base)
|
||||||
|
|
||||||
|
rows = sorted((p for p in listing
|
||||||
|
if wikimap.matches_prefix(p.get("title") or "", a.prefix)),
|
||||||
|
key=lambda p: p.get("title") or "")
|
||||||
|
if not rows:
|
||||||
|
print("no page at or under %r in %s" % (a.prefix, slug) if a.prefix
|
||||||
|
else "%s has no wiki pages" % slug)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if a.titles:
|
||||||
|
for p in rows:
|
||||||
|
print(p.get("title") or "")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
head = ["title", "sub_url", "updated", "by"] + (["url"] if a.urls else [])
|
||||||
|
print("| %s |" % " | ".join(head))
|
||||||
|
print("|%s|" % "|".join("---" for _ in head))
|
||||||
|
for p in rows:
|
||||||
|
c = (p.get("last_commit") or {}).get("author") or {}
|
||||||
|
row = [cell(p.get("title")), "`%s`" % cell(p.get("sub_url")),
|
||||||
|
cell((c.get("date") or "")[:10]), cell(c.get("name"))]
|
||||||
|
if a.urls:
|
||||||
|
row.append(cell(p.get("html_url")))
|
||||||
|
print("| %s |" % " | ".join(row))
|
||||||
|
print("\n%d page(s) in %s" % (len(rows), slug))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
wiki_pull.py — fetch wiki pages into a local space.
|
||||||
|
|
||||||
|
wiki_pull.py the whole wiki
|
||||||
|
wiki_pull.py --prefix "Simple Chains" a page and everything under it
|
||||||
|
wiki_pull.py --repo other/repo --space docs from elsewhere, into a named space
|
||||||
|
|
||||||
|
The wiki is flat, so "a page and its children" is a prefix test on the title,
|
||||||
|
run against one listing call. One GET per page follows. There is no tree
|
||||||
|
endpoint to ask for a subtree, and no way to fetch bodies in bulk.
|
||||||
|
|
||||||
|
Pulling OVERWRITES the local body — a fetch, not a merge, the same stance the
|
||||||
|
issue store takes. `sha` and `synced` tell you how old your copy is; re-pull
|
||||||
|
when it matters. Nothing tracks drift.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
wiki_pull.py [--repo owner/repo] [--prefix TITLE] [--space SPACE]
|
||||||
|
[--dry-run] [--out DIR]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, _HERE)
|
||||||
|
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
|
||||||
|
sys.path.insert(0, os.path.join(_HERE, "..", "..", "page", "scripts"))
|
||||||
|
import _gitea # noqa: E402
|
||||||
|
import page # noqa: E402
|
||||||
|
import wikimap # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
|
||||||
|
ap.add_argument("--prefix", default="", help="only titles at or under this")
|
||||||
|
ap.add_argument("--space", help="local space (default: the owner/repo slug)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
login = _gitea.require_login()
|
||||||
|
base = _gitea.repo_base(a.repo)
|
||||||
|
slug = _gitea.repo_slug(login, a.repo)
|
||||||
|
space = a.space or slug
|
||||||
|
root = a.out or page.WIKI_ROOT
|
||||||
|
space_dir = page.space_root(space, root)
|
||||||
|
|
||||||
|
listing = _gitea.paginate(login, "%s/wiki/pages" % base)
|
||||||
|
if not isinstance(listing, list):
|
||||||
|
_gitea.die("unexpected listing from %s/wiki/pages" % base)
|
||||||
|
|
||||||
|
wanted = [p for p in listing
|
||||||
|
if wikimap.matches_prefix(p.get("title") or "", a.prefix)]
|
||||||
|
if not wanted:
|
||||||
|
if a.prefix:
|
||||||
|
_gitea.die("no page at or under %r in %s (%d page(s) in the wiki)"
|
||||||
|
% (a.prefix, slug, len(listing)))
|
||||||
|
_gitea.die("%s has no wiki pages" % slug)
|
||||||
|
|
||||||
|
manifest = page.load_manifest(space, root)
|
||||||
|
|
||||||
|
# Two remote titles can land on one local path — Gitea keeps them apart with
|
||||||
|
# its `.-` marker, a filesystem does not. Caught before anything is written,
|
||||||
|
# because the failure mode otherwise is one page silently overwriting
|
||||||
|
# another and the manifest pointing both entries at the survivor.
|
||||||
|
seen = {}
|
||||||
|
for p in wanted:
|
||||||
|
seen.setdefault(page.path_for_title(p["title"]), []).append(p["title"])
|
||||||
|
clashes = {k: v for k, v in seen.items() if len(v) > 1}
|
||||||
|
for path, titles in sorted(clashes.items()):
|
||||||
|
sys.stderr.write("collision: %s <- %s\n" % (path, " | ".join(titles)))
|
||||||
|
|
||||||
|
created = not os.path.isdir(space_dir)
|
||||||
|
n = 0
|
||||||
|
for p in sorted(wanted, key=lambda x: x.get("title") or ""):
|
||||||
|
title = p["title"]
|
||||||
|
relpath = page.path_for_title(title)
|
||||||
|
if relpath in clashes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if a.dry_run:
|
||||||
|
print("%-44s %s" % (relpath, title))
|
||||||
|
n += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
full = _gitea.api(login, wikimap.page_endpoint(base, p["sub_url"]))
|
||||||
|
if not isinstance(full, dict):
|
||||||
|
_gitea.warn("could not read %r; skipped" % title)
|
||||||
|
continue
|
||||||
|
text = wikimap.decode(full)
|
||||||
|
|
||||||
|
dest = os.path.join(space_dir, relpath)
|
||||||
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||||
|
with open(dest, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
# The prior entry is the base so `order` — a local decision the wiki
|
||||||
|
# cannot hold — survives a pull.
|
||||||
|
e = dict(manifest["pages"].get(relpath, {}))
|
||||||
|
e.update(wikimap.from_payload(full))
|
||||||
|
e["synced"] = _gitea.now_iso()
|
||||||
|
# What is on disk is now exactly what is published, so push has nothing
|
||||||
|
# to do until the file is edited.
|
||||||
|
e["pushed"] = page.body_hash(text)
|
||||||
|
manifest["pages"][relpath] = e
|
||||||
|
print("%-44s %s" % (relpath, title))
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
if a.dry_run:
|
||||||
|
print("\ndry run — %d page(s) would be written to %s" % (n, space_dir))
|
||||||
|
return 1 if clashes else 0
|
||||||
|
|
||||||
|
page.save_manifest(manifest, root)
|
||||||
|
if created:
|
||||||
|
sys.stderr.write("created space %s\n" % space_dir)
|
||||||
|
print("\n%d page(s) from %s -> %s" % (n, slug, space_dir))
|
||||||
|
if clashes:
|
||||||
|
sys.stderr.write("%d collision(s) skipped — rename them in the wiki\n"
|
||||||
|
% len(clashes))
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
wiki_push.py — publish a local space to a wiki.
|
||||||
|
|
||||||
|
wiki_push.py -m "Import the simple-chains discussion"
|
||||||
|
wiki_push.py --prefix "Simple Chains/Ideas" -m "Rework B-04"
|
||||||
|
wiki_push.py -m "Fix the send-timing table" Simple-Chains/Ideas/Send-timing.md
|
||||||
|
|
||||||
|
Every page in the selection is compared against `pushed` — the hash of what was
|
||||||
|
last published — and only the ones that differ are sent. That is the whole of
|
||||||
|
change detection: no timestamps, no drift model.
|
||||||
|
|
||||||
|
A page with no `sub_url` is created; a page with one is edited in place. The
|
||||||
|
title comes from the manifest, never re-derived from the file, because sending
|
||||||
|
a different title to the edit endpoint is a RENAME and leaves nothing behind at
|
||||||
|
the old address.
|
||||||
|
|
||||||
|
Pushing is additive. A page deleted locally is NOT deleted in the wiki — the
|
||||||
|
manifest simply stops mentioning it. Removing a published page is an explicit
|
||||||
|
act; do it in the web UI or with a DELETE through /tea:use.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
wiki_push.py -m MESSAGE [--space SPACE] [--repo owner/repo]
|
||||||
|
[--prefix TITLE] [--dry-run] [--out DIR] [PATH-or-TITLE ...]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, _HERE)
|
||||||
|
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
|
||||||
|
sys.path.insert(0, os.path.join(_HERE, "..", "..", "page", "scripts"))
|
||||||
|
import _gitea # noqa: E402
|
||||||
|
import page # noqa: E402
|
||||||
|
import wikimap # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def select(manifest, prefix, targets):
|
||||||
|
"""The pages to consider, in tree order.
|
||||||
|
|
||||||
|
A positional argument matches a manifest path or a title, exactly. Exact
|
||||||
|
because a near-miss that silently selects nothing is indistinguishable from
|
||||||
|
a clean no-op run, and the operator finds out only when the page never
|
||||||
|
appears."""
|
||||||
|
pages = (page.children_of(manifest, prefix) if prefix
|
||||||
|
else page.sorted_pages(manifest))
|
||||||
|
if not targets:
|
||||||
|
return pages, []
|
||||||
|
want, chosen, hit = set(targets), [], set()
|
||||||
|
for p, e in pages:
|
||||||
|
if p in want or e.get("title") in want:
|
||||||
|
chosen.append((p, e))
|
||||||
|
hit.add(p if p in want else e.get("title"))
|
||||||
|
return chosen, sorted(want - hit)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("targets", nargs="*", metavar="PATH-or-TITLE")
|
||||||
|
ap.add_argument("-m", "--message", required=True,
|
||||||
|
help="wiki commit message for this push")
|
||||||
|
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
|
||||||
|
ap.add_argument("--space", help="local space (default: the owner/repo slug)")
|
||||||
|
ap.add_argument("--prefix", default="", help="only titles at or under this")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
login = _gitea.require_login()
|
||||||
|
base = _gitea.repo_base(a.repo)
|
||||||
|
slug = _gitea.repo_slug(login, a.repo)
|
||||||
|
space = a.space or slug
|
||||||
|
root = a.out or page.WIKI_ROOT
|
||||||
|
space_dir = page.space_root(space, root)
|
||||||
|
if not os.path.isdir(space_dir):
|
||||||
|
_gitea.die("no such space: %s (looked in %s). Import or pull first."
|
||||||
|
% (space, space_dir))
|
||||||
|
|
||||||
|
manifest = page.load_manifest(space, root)
|
||||||
|
if not manifest["pages"]:
|
||||||
|
_gitea.die("space %s has no pages in its manifest" % space)
|
||||||
|
|
||||||
|
chosen, missing = select(manifest, a.prefix, a.targets)
|
||||||
|
for t in missing:
|
||||||
|
_gitea.warn("not in the manifest: %s" % t)
|
||||||
|
if not chosen:
|
||||||
|
_gitea.die("nothing selected")
|
||||||
|
|
||||||
|
created = updated = skipped = 0
|
||||||
|
for relpath, e in chosen:
|
||||||
|
title = e.get("title")
|
||||||
|
full = os.path.join(space_dir, relpath)
|
||||||
|
if not title:
|
||||||
|
_gitea.warn("%s has no title in the manifest; skipped" % relpath)
|
||||||
|
continue
|
||||||
|
if not os.path.isfile(full):
|
||||||
|
_gitea.warn("%s is in the manifest but not on disk; skipped" % relpath)
|
||||||
|
continue
|
||||||
|
with open(full, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
h = page.body_hash(text)
|
||||||
|
|
||||||
|
if e.get("sub_url") and h == e.get("pushed"):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
verb = "create" if not e.get("sub_url") else "update"
|
||||||
|
print("%-7s %-44s %s" % (verb, relpath, title))
|
||||||
|
if a.dry_run:
|
||||||
|
created += verb == "create"
|
||||||
|
updated += verb == "update"
|
||||||
|
continue
|
||||||
|
|
||||||
|
if verb == "create":
|
||||||
|
payload = wikimap.new_payload(title, text, a.message)
|
||||||
|
got = _gitea.api(login, "%s/wiki/new" % base, method="POST",
|
||||||
|
payload=payload, payload_name="wiki-new",
|
||||||
|
out_root=space_dir)
|
||||||
|
else:
|
||||||
|
payload = wikimap.edit_payload(title, text, a.message)
|
||||||
|
got = _gitea.api(login, wikimap.page_endpoint(base, e["sub_url"]),
|
||||||
|
method="PATCH", payload=payload,
|
||||||
|
payload_name="wiki-edit", out_root=space_dir)
|
||||||
|
|
||||||
|
if not isinstance(got, dict) or not got.get("sub_url"):
|
||||||
|
_gitea.warn("%s: no page returned; the manifest is unchanged for it"
|
||||||
|
% title)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# sub_url comes back from Gitea and is stored as given. It is the only
|
||||||
|
# address this page has, and it is not something we could have computed.
|
||||||
|
e.update(wikimap.from_payload(got))
|
||||||
|
e["synced"] = _gitea.now_iso()
|
||||||
|
e["pushed"] = h
|
||||||
|
manifest["pages"][relpath] = e
|
||||||
|
created += verb == "create"
|
||||||
|
updated += verb == "update"
|
||||||
|
|
||||||
|
if a.dry_run:
|
||||||
|
print("\ndry run — %d to create, %d to update, %d unchanged"
|
||||||
|
% (created, updated, skipped))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
page.save_manifest(manifest, root)
|
||||||
|
print("\n%d created, %d updated, %d unchanged -> %s wiki"
|
||||||
|
% (created, updated, skipped, slug))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
wikimap.py — md <-> Gitea wiki 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 page; give it a page 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 (page.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
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
title title verbatim, both ways; `/` is the
|
||||||
|
only hierarchy either side has
|
||||||
|
path — local only; derived from the title
|
||||||
|
order — local only; the wiki cannot sort
|
||||||
|
body content_base64 base64, utf-8, verbatim
|
||||||
|
— sub_url lands in the manifest as sub_url
|
||||||
|
— last_commit.sha lands as sha
|
||||||
|
— html_url lands as url
|
||||||
|
|
||||||
|
sub_url is the identity, and it is NOT derivable
|
||||||
|
------------------------------------------------
|
||||||
|
Gitea stores a wiki page as one flat file whose name it escapes from the title,
|
||||||
|
and the escaping is not a mapping worth reimplementing:
|
||||||
|
|
||||||
|
"Abstract Issue" -> Abstract-Issue.md space -> dash
|
||||||
|
"zz-probe/child" -> zz-probe%2Fchild.-.md / -> %2F, and a
|
||||||
|
LITERAL dash forces a
|
||||||
|
`.-` marker so the two
|
||||||
|
cases stay distinct
|
||||||
|
|
||||||
|
Every rule there is Gitea's to change. So `sub_url` is read back from whatever
|
||||||
|
the API returned and stored; it is never constructed here, and a caller that
|
||||||
|
needs to address a page fetches the listing rather than guessing. Building one
|
||||||
|
by hand is how you get a second page instead of an edit.
|
||||||
|
|
||||||
|
The wiki is flat, and only titles are structured
|
||||||
|
------------------------------------------------
|
||||||
|
There are no directories. A real subdirectory committed into the wiki's git
|
||||||
|
repository is invisible to the API and to the web UI — a ghost file. All nesting
|
||||||
|
lives in the title, which is why `page.py` treats `/` as its only separator.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# A page's whole shape on the wire, for reference and for tests. Gitea also
|
||||||
|
# returns `commit_count`, `sidebar` and `footer` on a single-page GET; none of
|
||||||
|
# them describe the page itself, so none of them cross.
|
||||||
|
WIRE_KEYS = ("title", "sub_url", "html_url", "content_base64", "last_commit")
|
||||||
|
|
||||||
|
|
||||||
|
def decode(payload):
|
||||||
|
"""content_base64 -> text. Missing content is "" and not None: a page that
|
||||||
|
exists with an empty body is a real state, and the caller writing a file
|
||||||
|
should not have to tell the two apart."""
|
||||||
|
b = payload.get("content_base64") or ""
|
||||||
|
return base64.b64decode(b).decode("utf-8", "replace") if b else ""
|
||||||
|
|
||||||
|
|
||||||
|
def encode(text):
|
||||||
|
return base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def from_payload(payload):
|
||||||
|
"""Gitea JSON -> the manifest fields the wiki layer owns, plus the title
|
||||||
|
the domain owns. The caller merges this into the existing entry so that
|
||||||
|
domain keys it does not mention (`order`) survive."""
|
||||||
|
commit = payload.get("last_commit") or {}
|
||||||
|
author = commit.get("author") or {}
|
||||||
|
return {
|
||||||
|
"title": payload.get("title") or "",
|
||||||
|
"sub_url": payload.get("sub_url") or "",
|
||||||
|
"url": payload.get("html_url") or "",
|
||||||
|
"sha": commit.get("sha") or "",
|
||||||
|
"remote-updated": author.get("date") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def new_payload(title, text, message):
|
||||||
|
"""POST /repos/{owner}/{repo}/wiki/new.
|
||||||
|
|
||||||
|
`title` carries the hierarchy; Gitea derives the filename from it and
|
||||||
|
returns the sub_url it settled on. `message` is the wiki commit message —
|
||||||
|
the operator's words, not a generated one, because this is the only record
|
||||||
|
of why a page changed."""
|
||||||
|
return {"title": title, "content_base64": encode(text), "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
def edit_payload(title, text, message):
|
||||||
|
"""PATCH /repos/{owner}/{repo}/wiki/page/{sub_url}.
|
||||||
|
|
||||||
|
The same shape as a create. Sending the unchanged title is a no-op; sending
|
||||||
|
a different one is a RENAME, which moves the file and leaves nothing at the
|
||||||
|
old sub_url — so callers pass the title from the manifest unless the
|
||||||
|
operator asked for a rename."""
|
||||||
|
return {"title": title, "content_base64": encode(text), "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
def page_endpoint(base, sub_url):
|
||||||
|
"""The address of one page. `sub_url` goes in verbatim — Gitea hands it
|
||||||
|
back already escaped (`%2F` and all), and re-encoding it here would produce
|
||||||
|
a path that resolves to nothing."""
|
||||||
|
return "%s/wiki/page/%s" % (base, sub_url)
|
||||||
|
|
||||||
|
|
||||||
|
def revisions_endpoint(base, sub_url):
|
||||||
|
return "%s/wiki/revisions/%s" % (base, sub_url)
|
||||||
|
|
||||||
|
|
||||||
|
def matches_prefix(title, prefix):
|
||||||
|
"""Is this page at, or under, a title prefix?
|
||||||
|
|
||||||
|
The wiki being flat, "children" is exactly this test and nothing more:
|
||||||
|
there is no tree to walk, only a naming convention to trust. An empty
|
||||||
|
prefix matches everything."""
|
||||||
|
if not prefix:
|
||||||
|
return True
|
||||||
|
return title == prefix or title.startswith(prefix + "/")
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Checkbox state survives a pull; everything else in the body does not.
|
||||||
|
|
||||||
|
Two levels, on purpose. `map.merge_checkbox_state` is pure, so most of the rule
|
||||||
|
is pinned down with plain strings and no store anywhere. The pull tests then
|
||||||
|
prove the rule is actually wired into the write path, with the transport
|
||||||
|
stubbed at the one seam `test_push_dependencies.py` uses — `_gitea.api`, the
|
||||||
|
single function that shells out to `tea`. Nothing here touches a network, and
|
||||||
|
no test may ever be made to.
|
||||||
|
|
||||||
|
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import urllib.parse
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||||
|
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|
||||||
|
import _gitea # noqa: E402
|
||||||
|
import issue # noqa: E402
|
||||||
|
import map as gmap # noqa: E402
|
||||||
|
import pull # noqa: E402
|
||||||
|
|
||||||
|
REPO = "claude-skills/tea"
|
||||||
|
BASE = "repos/%s" % REPO
|
||||||
|
|
||||||
|
|
||||||
|
def body(*criteria, **kw):
|
||||||
|
"""A body in the canonical shape, with the given `## Acceptance criteria`."""
|
||||||
|
summary = kw.get("summary", "Прозаическое описание.")
|
||||||
|
return ("## Summary\n%s\n\n## Spec\nnone\n\n## Acceptance criteria\n%s\n"
|
||||||
|
% (summary, "\n".join(criteria))).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the rule itself — pure, no store, no tracker
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MergeCheckboxStateTest(unittest.TestCase):
|
||||||
|
"""`[x]` wins from whichever side has it, for a matching item text."""
|
||||||
|
|
||||||
|
def test_a_local_tick_survives_the_overwrite(self):
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] первое", "- [ ] второе"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_a_remote_tick_is_kept(self):
|
||||||
|
got = gmap.merge_checkbox_state(body("- [x] первое", "- [ ] второе"),
|
||||||
|
body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_both_sides_ticked_is_still_ticked(self):
|
||||||
|
one = body("- [x] первое")
|
||||||
|
self.assertEqual(gmap.merge_checkbox_state(one, one), one)
|
||||||
|
|
||||||
|
def test_the_union_is_taken_item_by_item(self):
|
||||||
|
got = gmap.merge_checkbox_state(
|
||||||
|
body("- [x] первое", "- [ ] второе", "- [ ] третье"),
|
||||||
|
body("- [ ] первое", "- [x] второе", "- [ ] третье"))
|
||||||
|
self.assertEqual(got, body("- [x] первое", "- [x] второе", "- [ ] третье"))
|
||||||
|
|
||||||
|
def test_an_item_the_local_copy_does_not_have_comes_from_the_server(self):
|
||||||
|
"""Including its state — both states, in both directions."""
|
||||||
|
got = gmap.merge_checkbox_state(
|
||||||
|
body("- [x] новое сверху", "- [ ] новое снизу"),
|
||||||
|
body("- [x] что-то совсем другое"))
|
||||||
|
self.assertEqual(got, body("- [x] новое сверху", "- [ ] новое снизу"))
|
||||||
|
|
||||||
|
def test_prose_is_not_merged(self):
|
||||||
|
got = gmap.merge_checkbox_state(
|
||||||
|
body("- [ ] пункт", summary="Новый текст с сервера."),
|
||||||
|
body("- [x] пункт", summary="Старый локальный текст."))
|
||||||
|
self.assertIn("Новый текст с сервера.", got)
|
||||||
|
self.assertNotIn("Старый локальный текст.", got)
|
||||||
|
self.assertIn("- [x] пункт", got)
|
||||||
|
|
||||||
|
def test_a_heading_the_local_copy_added_is_gone(self):
|
||||||
|
remote = body("- [x] пункт")
|
||||||
|
got = gmap.merge_checkbox_state(remote, remote + "\n\n## Notes\nмои заметки\n")
|
||||||
|
self.assertEqual(got, remote)
|
||||||
|
|
||||||
|
def test_no_local_copy_returns_the_server_body_untouched(self):
|
||||||
|
remote = body("- [ ] пункт")
|
||||||
|
for local in (None, "", " \n"):
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, local), remote,
|
||||||
|
"local_body=%r rewrote the body" % local)
|
||||||
|
|
||||||
|
def test_nothing_ticked_locally_returns_the_same_object(self):
|
||||||
|
remote = body("- [ ] пункт")
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, body("- [ ] пункт")), remote)
|
||||||
|
|
||||||
|
def test_no_matching_item_returns_the_same_object(self):
|
||||||
|
remote = body("- [ ] пункт")
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] другой")), remote)
|
||||||
|
|
||||||
|
def test_a_body_with_no_checkboxes_at_all(self):
|
||||||
|
remote = "## Summary\nодна проза\n"
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, "- [x] пункт"), remote)
|
||||||
|
self.assertEqual(gmap.merge_checkbox_state("- [ ] пункт", remote), "- [ ] пункт")
|
||||||
|
|
||||||
|
def test_exactly_one_character_changes(self):
|
||||||
|
"""Ticking a box must not produce a diff wider than the state."""
|
||||||
|
remote = body("- [ ] пункт", "- [ ] второй")
|
||||||
|
got = gmap.merge_checkbox_state(remote, body("- [x] пункт", "- [ ] второй"))
|
||||||
|
diff = [i for i, (a, b) in enumerate(zip(remote, got)) if a != b]
|
||||||
|
self.assertEqual(len(remote), len(got))
|
||||||
|
self.assertEqual(len(diff), 1)
|
||||||
|
self.assertEqual((remote[diff[0]], got[diff[0]]), (" ", "x"))
|
||||||
|
|
||||||
|
def test_a_rewrapped_item_keeps_its_tick(self):
|
||||||
|
"""`Checkbox.text` joins continuation lines with one space, which is
|
||||||
|
the whole reason matching survives a reflow."""
|
||||||
|
remote = body("- [ ] длинный пункт, который сервер\n"
|
||||||
|
" перенёс на две строки")
|
||||||
|
got = gmap.merge_checkbox_state(
|
||||||
|
remote, body("- [x] длинный пункт, который сервер перенёс на две строки"))
|
||||||
|
self.assertIn("- [x] длинный пункт", got)
|
||||||
|
|
||||||
|
def test_a_reworded_item_does_not_keep_its_tick(self):
|
||||||
|
"""Different text is a different item. The tick stays with the wording
|
||||||
|
it was put on — this is a match, not a guess."""
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт про sqlc"),
|
||||||
|
body("- [x] пункт про SQLC"))
|
||||||
|
self.assertEqual(got, body("- [ ] пункт про sqlc"))
|
||||||
|
|
||||||
|
def test_the_marker_style_does_not_have_to_match(self):
|
||||||
|
"""`-`, `*` and `1.` are all checkbox markers to the domain parser, so
|
||||||
|
the item is the same item however the two sides chose to render it."""
|
||||||
|
got = gmap.merge_checkbox_state(body("1. [ ] пункт"), body("* [x] пункт"))
|
||||||
|
self.assertEqual(got, body("1. [x] пункт"))
|
||||||
|
|
||||||
|
def test_a_moved_item_keeps_its_tick(self):
|
||||||
|
"""Matching is on text alone; the section is not part of the key. An
|
||||||
|
item promoted out of `## Acceptance criteria` is the same item."""
|
||||||
|
remote = "## Issues\n- [ ] пункт\n"
|
||||||
|
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
||||||
|
self.assertEqual(got, "## Issues\n- [x] пункт\n")
|
||||||
|
|
||||||
|
def test_duplicate_text_is_read_as_a_set(self):
|
||||||
|
"""The documented reading: one ticked local item ticks every remote
|
||||||
|
line with that text. Pairing duplicates by order is the alternative,
|
||||||
|
and it is the one that can still drop a tick."""
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт", "- [ ] пункт"),
|
||||||
|
body("- [ ] пункт", "- [x] пункт"))
|
||||||
|
self.assertEqual(got, body("- [x] пункт", "- [x] пункт"))
|
||||||
|
|
||||||
|
def test_duplicate_text_never_loses_the_second_tick(self):
|
||||||
|
"""Two local lines, one remote: order-pairing would drop this tick."""
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт"),
|
||||||
|
body("- [ ] пункт", "- [x] пункт"))
|
||||||
|
self.assertEqual(got, body("- [x] пункт"))
|
||||||
|
|
||||||
|
def test_an_example_inside_a_code_fence_is_not_ticked(self):
|
||||||
|
"""The domain parser skips fences whole, and so does the merge: a
|
||||||
|
`- [ ]` in a fence is markup being shown, not a box anyone may tick."""
|
||||||
|
remote = "## Spec\n```md\n- [ ] пункт\n```\n\n## Acceptance criteria\n- [ ] пункт\n"
|
||||||
|
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
||||||
|
self.assertEqual(got, remote.replace("## Acceptance criteria\n- [ ] пункт",
|
||||||
|
"## Acceptance criteria\n- [x] пункт"))
|
||||||
|
self.assertIn("```md\n- [ ] пункт\n```", got)
|
||||||
|
|
||||||
|
def test_an_existing_capital_X_is_left_alone(self):
|
||||||
|
remote = body("- [X] пункт")
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] пункт")), remote)
|
||||||
|
|
||||||
|
def test_a_capital_X_locally_still_counts_as_ticked(self):
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [X] пункт"))
|
||||||
|
self.assertEqual(got, body("- [x] пункт"))
|
||||||
|
|
||||||
|
def test_unticking_in_the_web_does_not_survive(self):
|
||||||
|
"""The accepted price, pinned so nobody 'fixes' it by accident:
|
||||||
|
unticking is not monotone, so a box unticked upstream comes back.
|
||||||
|
Untick locally and push."""
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
||||||
|
self.assertEqual(got, body("- [x] пункт"))
|
||||||
|
|
||||||
|
def test_parsing_is_the_domain_layer_s(self):
|
||||||
|
"""The acceptance criterion, asserted rather than eyeballed: the rule
|
||||||
|
calls into skills/issue and defines no checkbox syntax of its own."""
|
||||||
|
with mock.patch.object(issue, "checkboxes", wraps=issue.checkboxes) as cb, \
|
||||||
|
mock.patch.object(issue, "set_checkbox", wraps=issue.set_checkbox) as sc:
|
||||||
|
gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
||||||
|
self.assertTrue(cb.called)
|
||||||
|
self.assertTrue(sc.called)
|
||||||
|
|
||||||
|
def test_no_checkbox_markup_is_spelled_out_in_the_sync_layer(self):
|
||||||
|
"""The same criterion from the other side: the bracket markup itself
|
||||||
|
appears nowhere under skills/sync/scripts. Knowing what `[ ]` looks
|
||||||
|
like is the domain's job, and there is only one copy of it."""
|
||||||
|
sync = os.path.join(_ROOT, "skills", "sync", "scripts")
|
||||||
|
for name in sorted(f for f in os.listdir(sync) if f.endswith(".py")):
|
||||||
|
with open(os.path.join(sync, name)) as f:
|
||||||
|
code = f.read().split('"""')[0::2] # docstrings dropped
|
||||||
|
for chunk in code:
|
||||||
|
for markup in ("[ xX]", "[xX]", "- [ ]", "- [x]"):
|
||||||
|
self.assertNotIn(markup, chunk,
|
||||||
|
"%s spells out %r" % (name, markup))
|
||||||
|
|
||||||
|
|
||||||
|
class FromApiTest(unittest.TestCase):
|
||||||
|
"""The seam between the rule and the translation."""
|
||||||
|
|
||||||
|
PAYLOAD = {"number": 42, "title": "T", "html_url": "u",
|
||||||
|
"body": body("- [ ] пункт")}
|
||||||
|
|
||||||
|
def test_local_body_is_optional_and_defaults_to_no_merge(self):
|
||||||
|
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO)
|
||||||
|
self.assertEqual(iss.body, body("- [ ] пункт"))
|
||||||
|
|
||||||
|
def test_local_body_contributes_its_ticks(self):
|
||||||
|
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO,
|
||||||
|
local_body=body("- [x] пункт"))
|
||||||
|
self.assertEqual(iss.body, body("- [x] пункт"))
|
||||||
|
|
||||||
|
def test_an_empty_remote_body_does_not_crash(self):
|
||||||
|
iss, _ = gmap.from_api({"number": 42, "title": "T", "body": None},
|
||||||
|
"an-issue", REPO, local_body=body("- [x] пункт"))
|
||||||
|
self.assertEqual(iss.body, "")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# pull.py — the rule wired into the write path
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FakeGitea(object):
|
||||||
|
"""A `tea api` that answers issues from memory and remembers the calls."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.issues = {}
|
||||||
|
|
||||||
|
def add(self, number, title, text, **kw):
|
||||||
|
p = {"number": number, "title": title, "body": text, "state": "open",
|
||||||
|
"comments": 0, "html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||||
|
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
||||||
|
"updated_at": "2026-08-10T00:00:00Z"}
|
||||||
|
p.update(kw)
|
||||||
|
self.issues[number] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
def api(self, login, endpoint, method="GET", payload=None, **kw):
|
||||||
|
self.calls.append((method, endpoint))
|
||||||
|
path, _, query = endpoint.partition("?")
|
||||||
|
params = dict(urllib.parse.parse_qsl(query))
|
||||||
|
|
||||||
|
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
|
||||||
|
if m and method == "GET":
|
||||||
|
return self.issues.get(int(m.group(1)))
|
||||||
|
if path == "%s/issues" % BASE and method == "GET":
|
||||||
|
if int(params.get("page", 1)) > 1:
|
||||||
|
return []
|
||||||
|
return [self.issues[n] for n in sorted(self.issues)]
|
||||||
|
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||||
|
|
||||||
|
|
||||||
|
class PullTestCase(unittest.TestCase):
|
||||||
|
"""A temp store and a fake transport."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
||||||
|
self.fake = FakeGitea()
|
||||||
|
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||||
|
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, True)
|
||||||
|
|
||||||
|
def write_local(self, id, text, number=42):
|
||||||
|
issue.save(self.root, issue.Issue(
|
||||||
|
id=id, title="An issue", body=text, labels=["type/task"],
|
||||||
|
origin="gitea", extra={"gitea": "%s#%d" % (REPO, number),
|
||||||
|
"url": "https://git.example/x", "synced": "old"}))
|
||||||
|
|
||||||
|
def run_pull(self, *argv):
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(out), \
|
||||||
|
contextlib.redirect_stderr(err):
|
||||||
|
pull.main()
|
||||||
|
return out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
def stored_body(self, id):
|
||||||
|
return issue.load(self.root, id).body
|
||||||
|
|
||||||
|
def raw(self, id):
|
||||||
|
"""The file on disk, byte for byte — metadata included."""
|
||||||
|
with open(issue.path_of(self.root, id)) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
class PullMergesTicksTest(PullTestCase):
|
||||||
|
|
||||||
|
def test_a_tick_made_locally_survives_the_pull(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_a_tick_made_in_the_web_lands_locally(self):
|
||||||
|
self.write_local("an-issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_the_rest_of_the_body_is_still_overwritten(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое",
|
||||||
|
summary="Локальная правка прозы."))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] новое с сервера",
|
||||||
|
summary="Серверная проза."))
|
||||||
|
self.run_pull("42")
|
||||||
|
got = self.stored_body("an-issue")
|
||||||
|
self.assertIn("Серверная проза.", got)
|
||||||
|
self.assertNotIn("Локальная правка прозы.", got)
|
||||||
|
self.assertIn("- [x] первое", got)
|
||||||
|
self.assertIn("- [ ] новое с сервера", got)
|
||||||
|
|
||||||
|
def test_an_item_the_server_added_arrives_ticked_if_the_server_ticked_it(self):
|
||||||
|
self.write_local("an-issue", body("- [ ] первое"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [x] новое с сервера"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [ ] первое", "- [x] новое с сервера"))
|
||||||
|
|
||||||
|
def test_filter_mode_merges_too(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое"))
|
||||||
|
self.run_pull("--label", "type/task")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
||||||
|
|
||||||
|
def test_a_retitled_issue_keeps_its_slug_and_its_ticks(self):
|
||||||
|
"""The merge hangs off the local id, which is resolved from the number
|
||||||
|
— a title change must not orphan the ticks."""
|
||||||
|
self.write_local("an-issue", body("- [x] первое"))
|
||||||
|
self.fake.add(42, "Completely different title", body("- [ ] первое"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
||||||
|
self.assertEqual(issue.load(self.root, "an-issue").title,
|
||||||
|
"Completely different title")
|
||||||
|
|
||||||
|
|
||||||
|
class PullIntoAnEmptyStoreTest(PullTestCase):
|
||||||
|
|
||||||
|
def test_no_local_file_writes_the_server_body_unchanged(self):
|
||||||
|
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_a_store_that_does_not_exist_yet_is_created_and_not_merged(self):
|
||||||
|
shutil.rmtree(self.root)
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое"))
|
||||||
|
_out, err = self.run_pull("42")
|
||||||
|
self.assertIn("created store", err)
|
||||||
|
self.assertEqual(self.stored_body("an-issue"), body("- [ ] первое"))
|
||||||
|
|
||||||
|
|
||||||
|
class CachedIsUnchangedTest(PullTestCase):
|
||||||
|
|
||||||
|
def test_a_skipped_issue_is_neither_read_nor_merged(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
before = self.raw("an-issue")
|
||||||
|
|
||||||
|
with mock.patch.object(gmap, "merge_checkbox_state") as merge:
|
||||||
|
out, _ = self.run_pull("42", "--cached")
|
||||||
|
|
||||||
|
merge.assert_not_called()
|
||||||
|
self.assertIn("(cached)", out)
|
||||||
|
self.assertEqual(self.raw("an-issue"), before)
|
||||||
|
|
||||||
|
def test_without_cached_the_same_issue_is_merged(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
|
||||||
|
class RoundTripTest(PullTestCase):
|
||||||
|
"""Pull twice with no change in between: the second is a no-op."""
|
||||||
|
|
||||||
|
def test_a_repeat_pull_does_not_churn_the_file(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
first = self.raw("an-issue")
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.raw("an-issue"), first)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Checkbox parsing, ticking, and the INDEX progress column.
|
||||||
|
|
||||||
|
Plain stdlib unittest — the scripts under test are stdlib-only by the layering
|
||||||
|
rule, and their tests have no business dragging in a dependency the code they
|
||||||
|
cover is forbidden to have. `skills/*/scripts/` are directories of scripts, not
|
||||||
|
packages, so they go on sys.path the same way the scripts do it to each other.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
Nothing here touches tmp/, the network, or the real store: every case builds
|
||||||
|
its own store in a TemporaryDirectory.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "skills", "issue", "scripts"))
|
||||||
|
|
||||||
|
import issue # noqa: E402
|
||||||
|
import issue_ac # noqa: E402
|
||||||
|
import issue_check # noqa: E402
|
||||||
|
import issue_index # noqa: E402
|
||||||
|
|
||||||
|
# Boxes in two different sections, a wrapped item, a fenced example, and a
|
||||||
|
# plain list item that is not a checkbox at all. Line numbers are 1-based:
|
||||||
|
# the items sit on lines 8, 9, 12, 14 and 15.
|
||||||
|
BODY = """## Summary
|
||||||
|
Что-то про задачу.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
none
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
- [x] wire-sqlc-appclick — первая часть
|
||||||
|
- [ ] add-pool-cfg — вторая часть
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] в `issue.py` есть функция разбора чекбоксов тела:
|
||||||
|
возвращает пункты с номером строки, состоянием и текстом
|
||||||
|
- [X] чекбоксы ищутся по всему телу
|
||||||
|
- [ ] пример в блоке кода не считается пунктом:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- [ ] это разметка из шаблона, а не галочка
|
||||||
|
- [x] и эта тоже
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- не входит в объём: доставка тела в трекер
|
||||||
|
"""
|
||||||
|
|
||||||
|
NO_BOXES = """## Summary
|
||||||
|
Тело без единой галочки.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
none
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- обычный пункт списка
|
||||||
|
- ещё один
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Metadata deliberately out of canonical order and missing optional keys, one
|
||||||
|
# item with trailing whitespace: a round-trip through Issue.to_text() would
|
||||||
|
# rewrite all of that, so this fixture catches a ticking path that re-renders
|
||||||
|
# the file instead of patching one character of it.
|
||||||
|
MESSY = """---
|
||||||
|
origin: local
|
||||||
|
labels: [type/task]
|
||||||
|
id: messy-issue
|
||||||
|
state: open
|
||||||
|
---
|
||||||
|
# Messy but valid
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Тело, которое нельзя перерисовывать.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
none
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] первый пункт
|
||||||
|
- [ ] второй пункт
|
||||||
|
- [ ] третий пункт
|
||||||
|
"""
|
||||||
|
|
||||||
|
TASK_BODY = """## Summary
|
||||||
|
Что нужно сделать.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
none
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Зачем это нужно.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] ничего ещё не сделано
|
||||||
|
- [ ] и это тоже не сделано
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def sole_difference(before, after):
|
||||||
|
"""The single character position at which the two strings differ.
|
||||||
|
|
||||||
|
Raises AssertionError when they differ in length or in more than one
|
||||||
|
place — the whole claim of `set_checkbox` is that this never happens."""
|
||||||
|
assert len(before) == len(after), (
|
||||||
|
"length changed: %d -> %d" % (len(before), len(after)))
|
||||||
|
diff = [i for i, (a, b) in enumerate(zip(before, after)) if a != b]
|
||||||
|
assert len(diff) == 1, "expected 1 differing character, got %d" % len(diff)
|
||||||
|
return diff[0]
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def store(**files):
|
||||||
|
"""A throwaway issue store: {id: file text}."""
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
for id, text in files.items():
|
||||||
|
with open(os.path.join(root, "%s.md" % id), "w", newline="") as f:
|
||||||
|
f.write(text)
|
||||||
|
yield root
|
||||||
|
|
||||||
|
|
||||||
|
def run(fn, *argv):
|
||||||
|
"""Call a script entry point, returning (exit code or None, stdout)."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
rc = fn(list(argv))
|
||||||
|
return rc, buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParse(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_finds_every_box_in_every_section(self):
|
||||||
|
items = issue.checkboxes(BODY)
|
||||||
|
self.assertEqual([c.index for c in items], [1, 2, 3, 4, 5])
|
||||||
|
self.assertEqual([c.line for c in items], [8, 9, 12, 14, 15])
|
||||||
|
self.assertEqual([c.checked for c in items],
|
||||||
|
[True, False, False, True, False])
|
||||||
|
self.assertEqual([c.section for c in items],
|
||||||
|
["## Issues", "## Issues"] + ["## Acceptance criteria"] * 3)
|
||||||
|
|
||||||
|
def test_boxes_outside_acceptance_criteria_are_items_too(self):
|
||||||
|
# A type/feature keeps its children under `## Issues`; binding the
|
||||||
|
# parser to one heading would lose them.
|
||||||
|
under_issues = [c for c in issue.checkboxes(BODY) if c.section == "## Issues"]
|
||||||
|
self.assertEqual(len(under_issues), 2)
|
||||||
|
self.assertTrue(under_issues[0].text.startswith("wire-sqlc-appclick"))
|
||||||
|
|
||||||
|
def test_continuation_line_is_part_of_the_item(self):
|
||||||
|
item = issue.checkboxes(BODY)[2]
|
||||||
|
self.assertEqual((item.line, item.end_line), (12, 13))
|
||||||
|
self.assertEqual(
|
||||||
|
item.text,
|
||||||
|
"в `issue.py` есть функция разбора чекбоксов тела: "
|
||||||
|
"возвращает пункты с номером строки, состоянием и текстом")
|
||||||
|
|
||||||
|
def test_fenced_example_is_not_an_item(self):
|
||||||
|
texts = [c.text for c in issue.checkboxes(BODY)]
|
||||||
|
self.assertNotIn("это разметка из шаблона, а не галочка", texts)
|
||||||
|
self.assertEqual(len(texts), 5)
|
||||||
|
|
||||||
|
def test_plain_list_item_is_not_a_checkbox(self):
|
||||||
|
self.assertNotIn("не входит в объём: доставка тела в трекер",
|
||||||
|
[c.text for c in issue.checkboxes(BODY)])
|
||||||
|
|
||||||
|
def test_markers_and_nesting(self):
|
||||||
|
text = ("* [ ] star\n"
|
||||||
|
"+ [x] plus\n"
|
||||||
|
"1. [ ] ordered\n"
|
||||||
|
"2) [x] ordered too\n"
|
||||||
|
" - [ ] nested\n"
|
||||||
|
"- [x]no space, not an item\n")
|
||||||
|
items = issue.checkboxes(text)
|
||||||
|
self.assertEqual([c.text for c in items],
|
||||||
|
["star", "plus", "ordered", "ordered too", "nested"])
|
||||||
|
self.assertEqual([c.checked for c in items],
|
||||||
|
[False, True, False, True, False])
|
||||||
|
|
||||||
|
def test_empty_text(self):
|
||||||
|
self.assertEqual(issue.checkboxes(""), [])
|
||||||
|
self.assertEqual(issue.checkboxes(None), [])
|
||||||
|
|
||||||
|
def test_line_numbers_are_relative_to_the_text_given(self):
|
||||||
|
# Same body, prefixed with a metadata block: the offsets move with it,
|
||||||
|
# which is what lets issue_ac.py work on a whole file.
|
||||||
|
head = "---\nid: x\nstate: open\n---\n# Title\n\n"
|
||||||
|
shift = head.count("\n")
|
||||||
|
self.assertEqual([c.line for c in issue.checkboxes(head + BODY)],
|
||||||
|
[c.line + shift for c in issue.checkboxes(BODY)])
|
||||||
|
|
||||||
|
def test_progress(self):
|
||||||
|
self.assertEqual(issue.checkbox_progress(BODY), (2, 5))
|
||||||
|
self.assertEqual(issue.checkbox_progress(NO_BOXES), (0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
class TestToggle(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_ticking_changes_exactly_one_character(self):
|
||||||
|
item = issue.checkboxes(BODY)[1] # line 9, unchecked
|
||||||
|
after = issue.set_checkbox(BODY, item, True)
|
||||||
|
at = sole_difference(BODY, after)
|
||||||
|
self.assertEqual(BODY[at], " ")
|
||||||
|
self.assertEqual(after[at], "x")
|
||||||
|
self.assertEqual(issue.checkbox_progress(after), (3, 5))
|
||||||
|
|
||||||
|
def test_unticking_changes_exactly_one_character(self):
|
||||||
|
item = issue.checkboxes(BODY)[0] # line 8, checked
|
||||||
|
after = issue.set_checkbox(BODY, item, False)
|
||||||
|
at = sole_difference(BODY, after)
|
||||||
|
self.assertEqual((BODY[at], after[at]), ("x", " "))
|
||||||
|
|
||||||
|
def test_every_item_toggles_in_isolation(self):
|
||||||
|
for item in issue.checkboxes(BODY):
|
||||||
|
after = issue.set_checkbox(BODY, item, not item.checked)
|
||||||
|
at = sole_difference(BODY, after)
|
||||||
|
self.assertEqual(after.splitlines()[item.line - 1].count("["), 1)
|
||||||
|
self.assertLess(at, len(BODY))
|
||||||
|
|
||||||
|
def test_no_op_when_already_in_that_state(self):
|
||||||
|
items = issue.checkboxes(BODY)
|
||||||
|
self.assertIs(issue.set_checkbox(BODY, items[0], True), BODY)
|
||||||
|
self.assertIs(issue.set_checkbox(BODY, items[1], False), BODY)
|
||||||
|
|
||||||
|
def test_capital_x_is_left_alone(self):
|
||||||
|
item = issue.checkboxes(BODY)[3] # `- [X]`
|
||||||
|
self.assertEqual(issue.set_checkbox(BODY, item, True), BODY)
|
||||||
|
|
||||||
|
def test_accepts_a_line_number(self):
|
||||||
|
after = issue.set_checkbox(BODY, 9, True)
|
||||||
|
self.assertEqual(after, issue.set_checkbox(BODY, issue.checkboxes(BODY)[1], True))
|
||||||
|
|
||||||
|
def test_refuses_a_line_that_is_not_a_checkbox(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
issue.set_checkbox(BODY, 1, True)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
issue.set_checkbox(BODY, 9999, True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestScript(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_lists_items_numbered_with_state(self):
|
||||||
|
with store(**{"messy-issue": MESSY}) as root:
|
||||||
|
rc, out = run(issue_ac.main, "messy-issue", "--out", root)
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("messy-issue — 0/3", out)
|
||||||
|
self.assertIn("## Acceptance criteria", out)
|
||||||
|
self.assertIn(" 1 [ ] первый пункт", out)
|
||||||
|
self.assertIn(" 3 [ ] третий пункт", out)
|
||||||
|
|
||||||
|
def test_check_by_number(self):
|
||||||
|
with store(**{"messy-issue": MESSY}) as root:
|
||||||
|
rc, out = run(issue_ac.main, "messy-issue", "--check", "2", "--out", root)
|
||||||
|
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||||
|
after = f.read()
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("checked", out)
|
||||||
|
self.assertIn("1/3", out)
|
||||||
|
self.assertEqual(issue.checkbox_progress(after), (1, 3))
|
||||||
|
|
||||||
|
def test_check_by_substring(self):
|
||||||
|
with store(**{"messy-issue": MESSY}) as root:
|
||||||
|
run(issue_ac.main, "messy-issue", "--check", "ТРЕТИЙ", "--out", root)
|
||||||
|
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||||
|
after = f.read()
|
||||||
|
self.assertTrue(issue.checkboxes(after)[2].checked)
|
||||||
|
self.assertEqual(issue.checkbox_progress(after), (1, 3))
|
||||||
|
|
||||||
|
def test_uncheck(self):
|
||||||
|
with store(**{"messy-issue": MESSY}) as root:
|
||||||
|
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
|
||||||
|
rc, out = run(issue_ac.main, "messy-issue", "--uncheck", "1", "--out", root)
|
||||||
|
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||||
|
after = f.read()
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("unchecked", out)
|
||||||
|
self.assertEqual(after, MESSY)
|
||||||
|
|
||||||
|
def test_toggling_through_the_script_changes_one_character_of_the_file(self):
|
||||||
|
with store(**{"messy-issue": MESSY}) as root:
|
||||||
|
path = os.path.join(root, "messy-issue.md")
|
||||||
|
with open(path) as f:
|
||||||
|
before = f.read()
|
||||||
|
run(issue_ac.main, "messy-issue", "--check", "второй", "--out", root)
|
||||||
|
with open(path) as f:
|
||||||
|
after = f.read()
|
||||||
|
at = sole_difference(before, after)
|
||||||
|
self.assertEqual((before[at], after[at]), (" ", "x"))
|
||||||
|
# The metadata block was neither reordered nor completed, and the
|
||||||
|
# trailing whitespace on the third item survived.
|
||||||
|
self.assertTrue(after.startswith("---\norigin: local\n"))
|
||||||
|
self.assertIn("- [ ] третий пункт \n", after)
|
||||||
|
|
||||||
|
def test_crlf_line_endings_survive(self):
|
||||||
|
crlf = MESSY.replace("\n", "\r\n")
|
||||||
|
with store(**{"messy-issue": crlf}) as root:
|
||||||
|
path = os.path.join(root, "messy-issue.md")
|
||||||
|
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
|
||||||
|
with open(path, newline="") as f:
|
||||||
|
after = f.read()
|
||||||
|
at = sole_difference(crlf, after)
|
||||||
|
self.assertEqual((crlf[at], after[at]), (" ", "x"))
|
||||||
|
self.assertEqual(after.count("\r\n"), crlf.count("\r\n"))
|
||||||
|
|
||||||
|
def test_ambiguous_substring_is_an_error_listing_the_matches(self):
|
||||||
|
with store(**{"messy-issue": MESSY}) as root:
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
run(issue_ac.main, "messy-issue", "--check", "пункт", "--out", root)
|
||||||
|
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||||
|
self.assertEqual(f.read(), MESSY) # nothing was picked
|
||||||
|
msg = str(cm.exception)
|
||||||
|
self.assertIn("matches 3 items", msg)
|
||||||
|
for want in ("1 [ ] первый пункт", "2 [ ] второй пункт", "3 [ ] третий пункт"):
|
||||||
|
self.assertIn(want, msg)
|
||||||
|
|
||||||
|
def test_substring_that_matches_nothing(self):
|
||||||
|
with store(**{"messy-issue": MESSY}) as root:
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
run(issue_ac.main, "messy-issue", "--check", "нетакого", "--out", root)
|
||||||
|
self.assertIn("nothing matches", str(cm.exception))
|
||||||
|
|
||||||
|
def test_number_out_of_range(self):
|
||||||
|
with store(**{"messy-issue": MESSY}) as root:
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
run(issue_ac.main, "messy-issue", "--check", "9", "--out", root)
|
||||||
|
self.assertIn("no item 9 — the issue has 3", str(cm.exception))
|
||||||
|
|
||||||
|
def test_issue_without_checkboxes(self):
|
||||||
|
with store(**{"plain": "---\nid: plain\n---\n# Plain\n\n" + NO_BOXES}) as root:
|
||||||
|
rc, out = run(issue_ac.main, "plain", "--out", root)
|
||||||
|
self.assertEqual((rc, out.strip()), (0, "plain — no checkboxes"))
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
run(issue_ac.main, "plain", "--check", "1", "--out", root)
|
||||||
|
self.assertIn("has no checkboxes", str(cm.exception))
|
||||||
|
|
||||||
|
def test_unknown_id(self):
|
||||||
|
with store() as root:
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
run(issue_ac.main, "nope", "--out", root)
|
||||||
|
self.assertIn("no issue 'nope'", str(cm.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexProgress(unittest.TestCase):
|
||||||
|
|
||||||
|
def files(self):
|
||||||
|
boxed = ("---\nid: boxed\nstate: open\nlabels: [type/task]\n"
|
||||||
|
"origin: local\n---\n# Boxed\n\n" + BODY)
|
||||||
|
plain = ("---\nid: plain\nstate: open\nlabels: [type/task]\n"
|
||||||
|
"origin: local\n---\n# Plain\n\n" + NO_BOXES)
|
||||||
|
return {"boxed": boxed, "plain": plain}
|
||||||
|
|
||||||
|
def index(self, root):
|
||||||
|
issue_index.build(root)
|
||||||
|
with open(os.path.join(root, "INDEX.md")) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def row(self, text, id):
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("| [%s]" % id):
|
||||||
|
return [c.strip() for c in line.split("|")]
|
||||||
|
self.fail("no row for %r in INDEX.md" % id)
|
||||||
|
|
||||||
|
def test_column_exists_and_counts_the_body(self):
|
||||||
|
with store(**self.files()) as root:
|
||||||
|
text = self.index(root)
|
||||||
|
self.assertIn("| id | state | progress | type |", text)
|
||||||
|
self.assertEqual(self.row(text, "boxed")[3], "2/5")
|
||||||
|
|
||||||
|
def test_blank_for_an_issue_without_checkboxes(self):
|
||||||
|
with store(**self.files()) as root:
|
||||||
|
text = self.index(root)
|
||||||
|
self.assertEqual(self.row(text, "plain")[3], "")
|
||||||
|
|
||||||
|
def test_recomputed_on_the_fly_not_stored(self):
|
||||||
|
with store(**self.files()) as root:
|
||||||
|
self.assertEqual(self.row(self.index(root), "boxed")[3], "2/5")
|
||||||
|
run(issue_ac.main, "boxed", "--check", "add-pool-cfg", "--out", root)
|
||||||
|
self.assertEqual(self.row(self.index(root), "boxed")[3], "3/5")
|
||||||
|
# No metadata field anywhere holds it.
|
||||||
|
with open(os.path.join(root, "boxed.md")) as f:
|
||||||
|
head = f.read().split("---")[1]
|
||||||
|
self.assertNotIn("3/5", head)
|
||||||
|
self.assertNotIn("progress", head)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckIgnoresUntickedBoxes(unittest.TestCase):
|
||||||
|
"""An unticked box is work not done yet, not a malformed issue."""
|
||||||
|
|
||||||
|
def test_validate_reports_nothing(self):
|
||||||
|
iss = issue.Issue(id="unticked-issue", title="Do the thing",
|
||||||
|
labels=["type/task"], body=TASK_BODY)
|
||||||
|
err, warn = issue.validate(iss, known_ids={"unticked-issue"})
|
||||||
|
self.assertEqual(err, [])
|
||||||
|
self.assertEqual(warn, [])
|
||||||
|
|
||||||
|
def test_issue_check_exits_clean(self):
|
||||||
|
text = ("---\nid: unticked-issue\nstate: open\nlabels: [type/task]\n"
|
||||||
|
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n"
|
||||||
|
"---\n# Do the thing\n\n" + TASK_BODY)
|
||||||
|
argv = sys.argv
|
||||||
|
with store(**{"unticked-issue": text}) as root:
|
||||||
|
sys.argv = ["issue_check.py", "--out", root]
|
||||||
|
try:
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
rc = issue_check.main()
|
||||||
|
finally:
|
||||||
|
sys.argv = argv
|
||||||
|
out = buf.getvalue()
|
||||||
|
self.assertEqual(rc, 0, out)
|
||||||
|
self.assertIn("ok unticked-issue", out)
|
||||||
|
self.assertNotIn("ERROR", out)
|
||||||
|
self.assertNotIn("warn ", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
The container <-> child edge points ONE way: container -> child.
|
||||||
|
|
||||||
|
A `type/feature` is closed when its children are closed, and that is a
|
||||||
|
dependency relation, so the container lists its children in `depends:`. A child
|
||||||
|
belongs to a feature, which is a membership relation, and membership has no
|
||||||
|
place in a dependency graph — so a child never names its container back. These
|
||||||
|
tests pin that direction down in all three places it shows up: the validator,
|
||||||
|
the desync warning, and the drawn tree.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
Stdlib only, like the scripts under test. `skills/*/scripts/` are directories,
|
||||||
|
not packages, so they go on sys.path by hand.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||||
|
if SCRIPTS not in sys.path:
|
||||||
|
sys.path.insert(0, SCRIPTS)
|
||||||
|
|
||||||
|
import issue # noqa: E402
|
||||||
|
import issue_check # noqa: E402
|
||||||
|
import issue_new # noqa: E402
|
||||||
|
import issue_tree # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def run(module, argv):
|
||||||
|
"""Call a script's main() with argv, returning (exit_code, stdout).
|
||||||
|
|
||||||
|
stderr is swallowed: issue_new.py notes on it when a `--depends` id is not
|
||||||
|
in the store yet, which is fine and not what these tests are about."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
old = sys.argv
|
||||||
|
sys.argv = [module.__name__ + ".py"] + argv
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
code = module.main()
|
||||||
|
except SystemExit as e: # argparse / sys.exit("msg")
|
||||||
|
code = e.code if isinstance(e.code, int) else 1
|
||||||
|
finally:
|
||||||
|
sys.argv = old
|
||||||
|
return (code or 0), buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def drawn(tree_output):
|
||||||
|
"""The rows inside the tree's code fence, header and blanks dropped."""
|
||||||
|
return [l for l in tree_output.splitlines() if l.rstrip().endswith(".md")]
|
||||||
|
|
||||||
|
|
||||||
|
class StoreCase(unittest.TestCase):
|
||||||
|
"""A scratch store per test. Never touches tmp/issues/."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = self._tmp.name
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
|
||||||
|
def new(self, type, id, title, depends=()):
|
||||||
|
argv = ["--type", type, "--id", id, "--title", title, "--out", self.root]
|
||||||
|
for d in depends:
|
||||||
|
argv += ["--depends", d]
|
||||||
|
code, _ = run(issue_new, argv)
|
||||||
|
self.assertEqual(code, 0, "issue_new.py failed for %s" % id)
|
||||||
|
|
||||||
|
def edit(self, id, old, new):
|
||||||
|
p = issue.path_of(self.root, id)
|
||||||
|
with open(p) as f:
|
||||||
|
text = f.read()
|
||||||
|
self.assertIn(old, text, "%s.md does not contain %r" % (id, old))
|
||||||
|
with open(p, "w") as f:
|
||||||
|
f.write(text.replace(old, new))
|
||||||
|
|
||||||
|
def fill_issues_section(self, id, *children):
|
||||||
|
"""Replace the type/feature template's `## Issues` placeholder."""
|
||||||
|
self.edit(id,
|
||||||
|
"- [ ] slug-дочернего-issue — краткое описание части\n- [ ] …\n",
|
||||||
|
"".join("- [ ] %s — часть\n" % c for c in children))
|
||||||
|
|
||||||
|
def container_and_child(self):
|
||||||
|
"""The canonical shape from references/format.md: the container names
|
||||||
|
the child in `depends:` AND in `## Issues`; the child names nobody."""
|
||||||
|
self.new("task", "child-y", "Child y")
|
||||||
|
self.new("feature", "feat-x", "Container x", depends=["child-y"])
|
||||||
|
self.fill_issues_section("feat-x", "child-y")
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalContainerIsClean(StoreCase):
|
||||||
|
"""A container from the template plus a child per format.md: green."""
|
||||||
|
|
||||||
|
def test_check_is_silent_and_exits_zero(self):
|
||||||
|
self.container_and_child()
|
||||||
|
code, out = run(issue_check, ["--out", self.root])
|
||||||
|
self.assertEqual(code, 0, out)
|
||||||
|
self.assertNotIn("ERROR", out)
|
||||||
|
self.assertNotIn("warn", out)
|
||||||
|
self.assertIn("ok feat-x", out)
|
||||||
|
self.assertIn("ok child-y", out)
|
||||||
|
|
||||||
|
def test_validate_reports_nothing_for_either_issue(self):
|
||||||
|
self.container_and_child()
|
||||||
|
issues = issue.load_all(self.root)
|
||||||
|
for id in ("feat-x", "child-y"):
|
||||||
|
err, warn = issue.validate(issues[id], known_ids=set(issues))
|
||||||
|
self.assertEqual((err, warn), ([], []), id)
|
||||||
|
|
||||||
|
def test_the_child_does_not_depend_on_its_container(self):
|
||||||
|
self.container_and_child()
|
||||||
|
issues = issue.load_all(self.root)
|
||||||
|
self.assertEqual(issues["feat-x"].depends, ["child-y"])
|
||||||
|
self.assertEqual(issues["child-y"].depends, [])
|
||||||
|
|
||||||
|
|
||||||
|
class OnlyOneDirectionIsLegal(StoreCase):
|
||||||
|
"""format.md and the validator agree on container -> child, and the
|
||||||
|
reverse edge is an error rather than a matter of taste."""
|
||||||
|
|
||||||
|
def test_the_reverse_edge_is_a_cycle(self):
|
||||||
|
self.container_and_child()
|
||||||
|
self.edit("child-y", "depends: []", "depends: [feat-x]")
|
||||||
|
code, out = run(issue_check, ["--out", self.root])
|
||||||
|
self.assertEqual(code, 1, out)
|
||||||
|
self.assertIn("ERROR cycle:", out)
|
||||||
|
self.assertIn("feat-x", out)
|
||||||
|
self.assertIn("child-y", out)
|
||||||
|
|
||||||
|
def test_a_child_pointing_at_its_container_alone_is_not_the_graph(self):
|
||||||
|
"""The shape format.md used to document: the child depends on the
|
||||||
|
container and the container's depends: is empty. It no longer matches
|
||||||
|
what the container's own `## Issues` says, so the check complains."""
|
||||||
|
self.new("feature", "feat-x", "Container x")
|
||||||
|
self.new("task", "child-y", "Child y", depends=["feat-x"])
|
||||||
|
self.fill_issues_section("feat-x", "child-y")
|
||||||
|
_, out = run(issue_check, ["--out", self.root])
|
||||||
|
self.assertIn("warn feat-x:", out)
|
||||||
|
|
||||||
|
def test_issues_section_is_an_edge_source_pointing_down(self):
|
||||||
|
body = "## Issues\n- [ ] child-y — часть\n"
|
||||||
|
self.assertEqual(issue.body_dep_refs(body), ["child-y"])
|
||||||
|
|
||||||
|
|
||||||
|
class WarningNamesItsOwnSection(StoreCase):
|
||||||
|
"""The desync warning quotes the section the reference came from, not
|
||||||
|
`## Depends on` unconditionally — a container has no such section."""
|
||||||
|
|
||||||
|
def test_container_warning_says_issues(self):
|
||||||
|
self.new("feature", "feat-x", "Container x")
|
||||||
|
self.new("task", "child-y", "Child y")
|
||||||
|
self.fill_issues_section("feat-x", "child-y") # but not depends:
|
||||||
|
issues = issue.load_all(self.root)
|
||||||
|
err, warn = issue.validate(issues["feat-x"], known_ids=set(issues))
|
||||||
|
self.assertEqual(err, [])
|
||||||
|
self.assertEqual(
|
||||||
|
warn, ["## Issues mentions 'child-y' but `depends:` does not list it"])
|
||||||
|
self.assertNotIn("## Depends on", "\n".join(warn))
|
||||||
|
body = issue.load(self.root, "feat-x").body
|
||||||
|
self.assertNotIn("## Depends on", body,
|
||||||
|
"the warning must not name a section that is not in the file")
|
||||||
|
|
||||||
|
def test_plain_issue_warning_still_says_depends_on(self):
|
||||||
|
self.new("task", "child-y", "Child y", depends=["migrate-schema"])
|
||||||
|
self.edit("child-y", "depends: [migrate-schema]", "depends: []")
|
||||||
|
issues = issue.load_all(self.root)
|
||||||
|
_, warn = issue.validate(issues["child-y"])
|
||||||
|
self.assertEqual(
|
||||||
|
warn,
|
||||||
|
["## Depends on mentions 'migrate-schema' but `depends:` does not list it"])
|
||||||
|
|
||||||
|
def test_each_reference_is_named_with_its_own_section(self):
|
||||||
|
body = ("## Depends on\n- migrate-schema\n\n"
|
||||||
|
"## Issues\n- [ ] child-y — часть\n")
|
||||||
|
self.assertEqual(
|
||||||
|
issue.body_dep_ref_sections(body),
|
||||||
|
[("## Depends on", "migrate-schema"), ("## Issues", "child-y")])
|
||||||
|
|
||||||
|
def test_body_dep_refs_still_returns_bare_strings(self):
|
||||||
|
"""skills/sync/scripts/map.py filters this list for `#N` refs."""
|
||||||
|
body = "## Depends on\n- migrate-schema\n- #42\n"
|
||||||
|
refs = issue.body_dep_refs(body)
|
||||||
|
self.assertEqual(refs, ["migrate-schema", "#42"])
|
||||||
|
self.assertTrue(all(isinstance(r, str) for r in refs))
|
||||||
|
|
||||||
|
def test_tracker_numbers_never_warn(self):
|
||||||
|
"""`#42` is a tracker handle, not a slug; `depends:` holds ids only."""
|
||||||
|
self.new("task", "child-y", "Child y")
|
||||||
|
self.edit("child-y", "## Motivation", "## Depends on\n- #42\n\n## Motivation")
|
||||||
|
issues = issue.load_all(self.root)
|
||||||
|
_, warn = issue.validate(issues["child-y"])
|
||||||
|
self.assertEqual(warn, [])
|
||||||
|
|
||||||
|
|
||||||
|
class TreePutsTheContainerOnTop(StoreCase):
|
||||||
|
|
||||||
|
def test_container_is_the_root_and_children_hang_below(self):
|
||||||
|
self.container_and_child()
|
||||||
|
code, out = run(issue_tree, ["--out", self.root])
|
||||||
|
self.assertEqual(code, 0, out)
|
||||||
|
rows = drawn(out)
|
||||||
|
self.assertEqual(len(rows), 2, out)
|
||||||
|
self.assertTrue(rows[0].startswith("feat-x "), out)
|
||||||
|
self.assertTrue(rows[1].startswith("└── child-y "), out)
|
||||||
|
self.assertEqual(out.count("child-y ["), 1, "child drawn more than once")
|
||||||
|
|
||||||
|
def test_the_container_is_the_only_root(self):
|
||||||
|
self.container_and_child()
|
||||||
|
_, out = run(issue_tree, ["--out", self.root])
|
||||||
|
self.assertIn("# Dependency tree — feat-x", out)
|
||||||
|
|
||||||
|
def test_two_children_hang_off_one_container(self):
|
||||||
|
self.new("task", "child-y", "Child y")
|
||||||
|
self.new("task", "child-z", "Child z")
|
||||||
|
self.new("feature", "feat-x", "Container x",
|
||||||
|
depends=["child-y", "child-z"])
|
||||||
|
self.fill_issues_section("feat-x", "child-y", "child-z")
|
||||||
|
code, out = run(issue_check, ["--out", self.root])
|
||||||
|
self.assertEqual(code, 0, out)
|
||||||
|
_, tree = run(issue_tree, ["--out", self.root])
|
||||||
|
self.assertEqual(tree.count("feat-x ["), 1,
|
||||||
|
"the container must not repeat once per child")
|
||||||
|
self.assertEqual([r.split(" ")[0] for r in drawn(tree)],
|
||||||
|
["feat-x", "├──", "└──"], tree)
|
||||||
|
self.assertIn("├── child-y ", tree)
|
||||||
|
self.assertIn("└── child-z ", tree)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
The local copy is dropped after a successful push, and pulled back on demand.
|
||||||
|
|
||||||
|
Two halves, and the second one is the one that matters:
|
||||||
|
|
||||||
|
1. **It deletes.** A confirmed create or PATCH removes `tmp/issues/<id>.md` and
|
||||||
|
`<id>.comments.md`, prints where the issue lives now, and leaves the ledger
|
||||||
|
behind so the slug can be found again. A pull puts the same file back —
|
||||||
|
same slug, same `depends:`, same body — including after a rename in Gitea
|
||||||
|
and on a machine that never had the file.
|
||||||
|
|
||||||
|
2. **It does not delete anything else, ever.** A transport that raised, a `tea`
|
||||||
|
that exited non-zero, an answer without a number, an answer for the wrong
|
||||||
|
issue, an `origin: local` issue nobody pushed: the file is still on disk.
|
||||||
|
A bug here destroys work, so every one of those paths is asserted
|
||||||
|
separately, and the assertion is always the same — `os.path.isfile`.
|
||||||
|
|
||||||
|
The transport is stubbed at `_gitea.api`, as `test_push_dependencies.py` does,
|
||||||
|
with one deliberate exception: the non-2xx test stubs `_gitea.subprocess`
|
||||||
|
instead and lets the REAL `_gitea.api` run, so "tea exited 1" is proved end to
|
||||||
|
end rather than assumed.
|
||||||
|
|
||||||
|
Nothing here touches a network, and nothing here touches the developer's store:
|
||||||
|
every test builds its own in a `tempfile.mkdtemp()`.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||||
|
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|
||||||
|
import _gitea # noqa: E402
|
||||||
|
import issue # noqa: E402
|
||||||
|
import map as gmap # noqa: E402
|
||||||
|
import pull # noqa: E402
|
||||||
|
import push # noqa: E402
|
||||||
|
|
||||||
|
# Captured before any test patches it — the non-2xx test needs the real thing.
|
||||||
|
REAL_API = _gitea.api
|
||||||
|
|
||||||
|
REPO = "claude-skills/tea"
|
||||||
|
BASE = "repos/%s" % REPO
|
||||||
|
LABELS = {"type/task": 901, "type/bug": 902}
|
||||||
|
LABEL_NAMES = {v: k for k, v in LABELS.items()}
|
||||||
|
|
||||||
|
BODY = """## Summary
|
||||||
|
Прозаическое описание задачи.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
skills/issue/references/format.md
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] что-нибудь работает
|
||||||
|
"""
|
||||||
|
|
||||||
|
BODY_WITH_DEPS = """## Summary
|
||||||
|
Прозаическое описание задачи.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
skills/issue/references/format.md
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
- first-thing — ставит фундамент, без него второй не собрать
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] что-нибудь работает
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# a tracker that can be both pushed to and pulled from
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FakeTracker(object):
|
||||||
|
"""`tea api` answered from memory, for push AND pull.
|
||||||
|
|
||||||
|
It keeps bodies the way Gitea does — verbatim, marker and all — which is
|
||||||
|
what makes the round-trip tests real: the slug that comes back is the one
|
||||||
|
that was actually stored on the far side, not one the test handed over."""
|
||||||
|
|
||||||
|
def __init__(self, next_number=101):
|
||||||
|
self.calls = []
|
||||||
|
self.next_number = next_number
|
||||||
|
self.issues = {} # number -> payload
|
||||||
|
self.deps = {} # number -> {(repo, number)}
|
||||||
|
# Failure injection, one write at a time.
|
||||||
|
self.raise_on_write = None # an exception instance to raise
|
||||||
|
self.answer_override = None # what a write answers instead
|
||||||
|
|
||||||
|
# -- state -------------------------------------------------------------
|
||||||
|
|
||||||
|
def store(self, number, title, body, **kw):
|
||||||
|
p = {"number": number, "title": title, "body": body, "state": "open",
|
||||||
|
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
|
||||||
|
"milestone": None, "ref": "test-branch",
|
||||||
|
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||||
|
"updated_at": "2026-08-10T00:00:00Z",
|
||||||
|
"repository": {"full_name": REPO}}
|
||||||
|
p.update(kw)
|
||||||
|
self.issues[number] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
def body_of(self, number):
|
||||||
|
return self.issues[number]["body"]
|
||||||
|
|
||||||
|
def rename(self, number, title):
|
||||||
|
self.issues[number]["title"] = title
|
||||||
|
|
||||||
|
def writes(self):
|
||||||
|
return [c for c in self.calls if c[0] != "GET"]
|
||||||
|
|
||||||
|
# -- the seam ----------------------------------------------------------
|
||||||
|
|
||||||
|
def api(self, login, endpoint, method="GET", payload=None,
|
||||||
|
payload_name=None, out_root=None, allow_fail=False):
|
||||||
|
self.calls.append((method, endpoint, payload))
|
||||||
|
path = endpoint.split("?")[0]
|
||||||
|
|
||||||
|
if path == "%s/labels" % BASE and method == "GET":
|
||||||
|
return [{"name": n, "id": i} for n, i in LABELS.items()]
|
||||||
|
|
||||||
|
if path.endswith("/comments"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if path.endswith("/dependencies"):
|
||||||
|
number = int(path.split("/issues/")[1].split("/")[0])
|
||||||
|
if method == "GET":
|
||||||
|
return [dict(self.issues[n], repository={"full_name": r})
|
||||||
|
for r, n in sorted(self.deps.get(number, set()))
|
||||||
|
if n in self.issues]
|
||||||
|
if method == "POST":
|
||||||
|
self.deps.setdefault(number, set()).add(
|
||||||
|
("%s/%s" % (payload["owner"], payload["repo"]),
|
||||||
|
int(payload["index"])))
|
||||||
|
return {"number": number}
|
||||||
|
|
||||||
|
if path == "%s/issues" % BASE and method == "POST":
|
||||||
|
return self._write(
|
||||||
|
lambda: self.store(self._next(), payload.get("title", ""),
|
||||||
|
payload.get("body", ""),
|
||||||
|
labels=self._labels(payload),
|
||||||
|
ref=payload.get("ref", "")))
|
||||||
|
|
||||||
|
if "/issues/" in path and method == "PATCH":
|
||||||
|
number = int(path.rsplit("/", 1)[1])
|
||||||
|
return self._write(
|
||||||
|
lambda: self.store(number, payload.get("title", ""),
|
||||||
|
payload.get("body", ""),
|
||||||
|
labels=self._labels(payload),
|
||||||
|
ref=payload.get("ref", "")))
|
||||||
|
|
||||||
|
if "/issues/" in path and method == "GET":
|
||||||
|
return self.issues.get(int(path.rsplit("/", 1)[1]))
|
||||||
|
|
||||||
|
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||||
|
|
||||||
|
# -- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _next(self):
|
||||||
|
n = self.next_number
|
||||||
|
self.next_number += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
def _labels(self, payload):
|
||||||
|
return [{"name": LABEL_NAMES[i]} for i in (payload or {}).get("labels") or []
|
||||||
|
if i in LABEL_NAMES]
|
||||||
|
|
||||||
|
def _write(self, do):
|
||||||
|
"""Every create and update goes through here, so a test can make one
|
||||||
|
fail without knowing which verb it was."""
|
||||||
|
if self.raise_on_write is not None:
|
||||||
|
raise self.raise_on_write
|
||||||
|
got = do()
|
||||||
|
if self.answer_override is not None:
|
||||||
|
return self.answer_override
|
||||||
|
return got
|
||||||
|
|
||||||
|
|
||||||
|
class StoreTestCase(unittest.TestCase):
|
||||||
|
"""A temp store, a fake tracker, and no git."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.mkdtemp(prefix="tea-drop-")
|
||||||
|
self.fake = FakeTracker()
|
||||||
|
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||||
|
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
|
||||||
|
mock.patch.object(push, "git_branch", lambda: "test-branch")):
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, True)
|
||||||
|
|
||||||
|
# -- fixtures ----------------------------------------------------------
|
||||||
|
|
||||||
|
def write_issue(self, id, title, body=BODY, depends=(), origin=issue.LOCAL,
|
||||||
|
extra=None):
|
||||||
|
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
||||||
|
depends=list(depends), origin=origin,
|
||||||
|
extra=dict(extra or {}))
|
||||||
|
issue.save(self.root, iss)
|
||||||
|
return iss
|
||||||
|
|
||||||
|
def write_comments(self, id, text="## comment 1 — someone — 2026-08-10\n\nтекст\n"):
|
||||||
|
p = _gitea.comments_path(self.root, id)
|
||||||
|
with open(p, "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
return p
|
||||||
|
|
||||||
|
# -- runners -----------------------------------------------------------
|
||||||
|
|
||||||
|
def run_push(self, *argv):
|
||||||
|
return self._run(push, "push.py", argv)
|
||||||
|
|
||||||
|
def run_pull(self, *argv):
|
||||||
|
return self._run(pull, "pull.py", argv)
|
||||||
|
|
||||||
|
def _run(self, mod, name, argv):
|
||||||
|
# Kept on self so a test that expects SystemExit can still read what
|
||||||
|
# went to stderr — the run never returns in that case.
|
||||||
|
self.out, self.err = io.StringIO(), io.StringIO()
|
||||||
|
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(self.out), \
|
||||||
|
contextlib.redirect_stderr(self.err):
|
||||||
|
mod.main()
|
||||||
|
return self.out.getvalue(), self.err.getvalue()
|
||||||
|
|
||||||
|
# -- assertions --------------------------------------------------------
|
||||||
|
|
||||||
|
def assertOnDisk(self, id, why=""):
|
||||||
|
self.assertTrue(os.path.isfile(issue.path_of(self.root, id)),
|
||||||
|
"%s.md was deleted%s" % (id, why and " — " + why))
|
||||||
|
|
||||||
|
def assertGone(self, id):
|
||||||
|
self.assertFalse(os.path.isfile(issue.path_of(self.root, id)),
|
||||||
|
"%s.md is still on disk" % id)
|
||||||
|
|
||||||
|
def ledger(self):
|
||||||
|
return _gitea.load_map(self.root)
|
||||||
|
|
||||||
|
def number_of(self, id):
|
||||||
|
for key, slug in self.ledger().items():
|
||||||
|
if slug == id:
|
||||||
|
return gmap.parse_remote_key(key)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# it deletes
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DropsAfterCreateTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_the_issue_file_is_gone(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
self.assertGone("a-thing")
|
||||||
|
|
||||||
|
def test_the_comment_thread_goes_with_it(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
cpath = self.write_comments("a-thing")
|
||||||
|
self.run_push()
|
||||||
|
self.assertFalse(os.path.isfile(cpath), "the thread outlived the issue")
|
||||||
|
|
||||||
|
def test_a_missing_thread_is_not_an_error(self):
|
||||||
|
"""Most issues have no comments file. Dropping must not care."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
out, _ = self.run_push()
|
||||||
|
self.assertIn("dropped", out)
|
||||||
|
|
||||||
|
def test_the_output_names_the_number_and_the_url(self):
|
||||||
|
"""The local path is gone, so this line is the only address left."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
out, _ = self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
self.assertIn("#%d" % n, out)
|
||||||
|
self.assertIn("https://git.example/%s/issues/%d" % (REPO, n), out)
|
||||||
|
self.assertIn("pull.py %d" % n, out)
|
||||||
|
|
||||||
|
def test_the_ledger_outlives_the_file(self):
|
||||||
|
"""`.remote.json` does not become garbage when the files go — it
|
||||||
|
becomes the only local record of which slug this number is."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
self.assertIsNotNone(n)
|
||||||
|
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
|
||||||
|
|
||||||
|
def test_the_ledger_is_written_before_the_file_is_removed(self):
|
||||||
|
"""Ordering, asserted rather than trusted: if the two were swapped, an
|
||||||
|
interrupted run would cost the slug and not just a re-pull."""
|
||||||
|
seen = {}
|
||||||
|
real_drop = push.drop_local
|
||||||
|
|
||||||
|
def spy(root, id):
|
||||||
|
seen["ledger"] = json.load(open(_gitea.map_path(root)))
|
||||||
|
return real_drop(root, id)
|
||||||
|
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
with mock.patch.object(push, "drop_local", spy):
|
||||||
|
self.run_push()
|
||||||
|
self.assertIn("a-thing", (seen.get("ledger") or {}).values())
|
||||||
|
|
||||||
|
|
||||||
|
class DropsAfterUpdateTest(StoreTestCase):
|
||||||
|
"""One rule, no exception: `--update` deletes too."""
|
||||||
|
|
||||||
|
def pushed_then_pulled(self, id="a-thing", body=BODY):
|
||||||
|
self.write_issue(id, "A thing", body=body)
|
||||||
|
self.run_push()
|
||||||
|
self.run_pull(str(self.number_of(id)))
|
||||||
|
self.assertOnDisk(id, "the pull should have put it back")
|
||||||
|
return id
|
||||||
|
|
||||||
|
def test_patch_deletes_the_file_too(self):
|
||||||
|
id = self.pushed_then_pulled()
|
||||||
|
out, _ = self.run_push("--update", id)
|
||||||
|
self.assertIn("updated", out)
|
||||||
|
self.assertGone(id)
|
||||||
|
|
||||||
|
def test_patch_deletes_the_thread_too(self):
|
||||||
|
id = self.pushed_then_pulled()
|
||||||
|
cpath = self.write_comments(id)
|
||||||
|
self.run_push("--update", id)
|
||||||
|
self.assertFalse(os.path.isfile(cpath))
|
||||||
|
|
||||||
|
def test_the_patch_really_went_out(self):
|
||||||
|
id = self.pushed_then_pulled()
|
||||||
|
self.run_push("--update", id)
|
||||||
|
self.assertTrue([c for c in self.fake.calls if c[0] == "PATCH"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# it deletes nothing else
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class NeverPushedIsNeverDroppedTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_a_local_issue_nobody_selected_stays(self):
|
||||||
|
self.write_issue("pushed-thing", "Pushed thing")
|
||||||
|
self.write_issue("kept-thing", "Kept thing")
|
||||||
|
self.run_push("pushed-thing")
|
||||||
|
self.assertGone("pushed-thing")
|
||||||
|
self.assertOnDisk("kept-thing", "it was never pushed")
|
||||||
|
|
||||||
|
def test_a_local_only_dependency_stays(self):
|
||||||
|
"""It is read (for the warning) but never sent, so never dropped."""
|
||||||
|
self.write_issue("first-thing", "First thing")
|
||||||
|
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||||
|
depends=["first-thing"])
|
||||||
|
_, err = self.run_push("second-thing")
|
||||||
|
self.assertIn("depends on local-only issue(s) first-thing", err)
|
||||||
|
self.assertOnDisk("first-thing", "it was never sent")
|
||||||
|
|
||||||
|
def test_dry_run_deletes_nothing(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push("--dry-run")
|
||||||
|
self.assertOnDisk("a-thing", "--dry-run must not write or delete")
|
||||||
|
self.assertEqual(self.fake.calls, [])
|
||||||
|
|
||||||
|
def test_a_format_violation_stops_before_anything_is_sent(self):
|
||||||
|
"""No type/* label: validation fails, nothing is sent, nothing goes."""
|
||||||
|
issue.save(self.root, issue.Issue(id="bad-thing", title="Bad thing",
|
||||||
|
body=BODY, labels=[]))
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_push("bad-thing")
|
||||||
|
self.assertOnDisk("bad-thing")
|
||||||
|
self.assertEqual(self.fake.writes(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class SurvivesEveryFailureTest(StoreTestCase):
|
||||||
|
"""The criterion that matters most. Each path is asserted on its own."""
|
||||||
|
|
||||||
|
def test_a_transport_exception_leaves_the_file(self):
|
||||||
|
"""`tea` could not be run at all — the exception propagates out of the
|
||||||
|
push and the delete is never reached."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.fake.raise_on_write = OSError("tea: command not found")
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
self.run_push()
|
||||||
|
self.assertOnDisk("a-thing", "the transport raised")
|
||||||
|
self.assertEqual(self.ledger(), {})
|
||||||
|
|
||||||
|
def test_a_non_2xx_answer_leaves_the_file(self):
|
||||||
|
"""The real `_gitea.api` against a `tea` that exits 1.
|
||||||
|
|
||||||
|
Stubbed one layer lower than every other test here on purpose: this is
|
||||||
|
the path a 422 or a 500 actually takes, and it ends in `die()`."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
|
||||||
|
def fake_run(cmd, capture_output=False, text=False):
|
||||||
|
creating = "-X" in cmd and cmd[cmd.index("-X") + 1] == "POST"
|
||||||
|
if creating:
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
returncode=1, stdout="",
|
||||||
|
stderr="422 Unprocessable Entity: validation failed")
|
||||||
|
if cmd[-1].split("?")[0].endswith("/labels"):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
returncode=0, stderr="",
|
||||||
|
stdout=json.dumps([{"name": n, "id": i}
|
||||||
|
for n, i in LABELS.items()]))
|
||||||
|
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||||
|
mock.patch.object(_gitea, "subprocess",
|
||||||
|
types.SimpleNamespace(run=fake_run)), \
|
||||||
|
self.assertRaises(SystemExit):
|
||||||
|
self.run_push()
|
||||||
|
|
||||||
|
self.assertOnDisk("a-thing", "tea exited non-zero")
|
||||||
|
|
||||||
|
def test_an_answer_without_a_number_leaves_the_file(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.fake.answer_override = {"ok": True, "message": "created"}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_push()
|
||||||
|
self.assertOnDisk("a-thing", "the answer carried no number")
|
||||||
|
|
||||||
|
def test_an_answer_that_is_not_an_object_leaves_the_file(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.fake.answer_override = ["something", "else"]
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_push()
|
||||||
|
self.assertOnDisk("a-thing")
|
||||||
|
|
||||||
|
def test_an_empty_answer_leaves_the_file(self):
|
||||||
|
"""`tea` exited 0 and printed nothing — api returns None."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.fake.answer_override = None
|
||||||
|
real_write = self.fake._write
|
||||||
|
self.fake._write = lambda do: (real_write(do), None)[1]
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_push()
|
||||||
|
self.assertOnDisk("a-thing")
|
||||||
|
|
||||||
|
def test_a_patch_answering_for_another_issue_leaves_the_file(self):
|
||||||
|
"""The mismatched-body case: we PATCHed #101 and #999 answered."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
self.run_pull(str(n))
|
||||||
|
self.assertOnDisk("a-thing")
|
||||||
|
|
||||||
|
self.fake.answer_override = {"number": 999, "html_url": "https://x"}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_push("--update", "a-thing")
|
||||||
|
self.assertOnDisk("a-thing", "the tracker answered for a different issue")
|
||||||
|
|
||||||
|
def test_the_error_says_the_file_is_untouched(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.fake.answer_override = {"ok": True}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_push()
|
||||||
|
self.assertIn("untouched", self.err.getvalue())
|
||||||
|
|
||||||
|
def test_a_failure_partway_through_keeps_what_has_not_been_sent(self):
|
||||||
|
"""Two issues, the second one fails. The first is legitimately gone —
|
||||||
|
Gitea confirmed it — and the second is still here."""
|
||||||
|
self.write_issue("aaa-thing", "Aaa thing")
|
||||||
|
self.write_issue("zzz-thing", "Zzz thing")
|
||||||
|
|
||||||
|
real_write = self.fake._write
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def once(do):
|
||||||
|
seen.append(1)
|
||||||
|
if len(seen) > 1:
|
||||||
|
return {"nope": True}
|
||||||
|
return real_write(do)
|
||||||
|
|
||||||
|
self.fake._write = once
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_push()
|
||||||
|
|
||||||
|
self.assertGone("aaa-thing")
|
||||||
|
self.assertOnDisk("zzz-thing", "its write never succeeded")
|
||||||
|
# And the one that did go up is in the ledger, so it is findable.
|
||||||
|
self.assertEqual(list(self.ledger().values()), ["aaa-thing"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmedNumberTest(unittest.TestCase):
|
||||||
|
"""The gate itself. Everything below it deletes a file."""
|
||||||
|
|
||||||
|
def test_a_plain_create_is_confirmed(self):
|
||||||
|
self.assertEqual(push.confirmed_number({"number": 42}), 42)
|
||||||
|
|
||||||
|
def test_a_matching_patch_is_confirmed(self):
|
||||||
|
self.assertEqual(push.confirmed_number({"number": 42}, 42), 42)
|
||||||
|
|
||||||
|
def test_a_mismatched_patch_is_not(self):
|
||||||
|
self.assertIsNone(push.confirmed_number({"number": 43}, 42))
|
||||||
|
|
||||||
|
def test_none_is_not(self):
|
||||||
|
self.assertIsNone(push.confirmed_number(None))
|
||||||
|
|
||||||
|
def test_a_list_is_not(self):
|
||||||
|
self.assertIsNone(push.confirmed_number([{"number": 42}]))
|
||||||
|
|
||||||
|
def test_a_missing_number_is_not(self):
|
||||||
|
self.assertIsNone(push.confirmed_number({"html_url": "https://x"}))
|
||||||
|
|
||||||
|
def test_a_string_number_is_not(self):
|
||||||
|
self.assertIsNone(push.confirmed_number({"number": "42"}))
|
||||||
|
|
||||||
|
def test_true_is_not_a_number(self):
|
||||||
|
"""`True` is an `int` in Python; `number: true` confirms nothing."""
|
||||||
|
self.assertIsNone(push.confirmed_number({"number": True}))
|
||||||
|
|
||||||
|
def test_zero_and_negatives_are_not(self):
|
||||||
|
self.assertIsNone(push.confirmed_number({"number": 0}))
|
||||||
|
self.assertIsNone(push.confirmed_number({"number": -1}))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the id marker
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class IdMarkerTest(unittest.TestCase):
|
||||||
|
"""map.py, pure — no store, no tracker."""
|
||||||
|
|
||||||
|
def test_the_marker_is_the_first_line(self):
|
||||||
|
got = gmap.with_id_marker("## Summary\nтекст", "a-thing")
|
||||||
|
self.assertEqual(got.splitlines()[0], "<!-- tea:id a-thing -->")
|
||||||
|
self.assertEqual(got.splitlines()[1], "")
|
||||||
|
|
||||||
|
def test_strip_is_the_exact_inverse(self):
|
||||||
|
for body in ("## Summary\nтекст", "", "one line",
|
||||||
|
"## Summary\n\n- [ ] пункт\n\n## Spec\nnone"):
|
||||||
|
self.assertEqual(gmap.strip_id_marker(gmap.with_id_marker(body, "x")),
|
||||||
|
body)
|
||||||
|
|
||||||
|
def test_a_body_with_no_marker_comes_back_byte_for_byte(self):
|
||||||
|
body = "## Summary\n\n весь текст \n\n\n"
|
||||||
|
self.assertEqual(gmap.strip_id_marker(body), body)
|
||||||
|
|
||||||
|
def test_marking_twice_still_leaves_one(self):
|
||||||
|
once = gmap.with_id_marker("текст", "a-thing")
|
||||||
|
twice = gmap.with_id_marker(once, "a-thing")
|
||||||
|
self.assertEqual(once, twice)
|
||||||
|
self.assertEqual(twice.count("tea:id"), 1)
|
||||||
|
|
||||||
|
def test_remarking_under_a_new_slug_replaces_rather_than_adds(self):
|
||||||
|
got = gmap.with_id_marker(gmap.with_id_marker("текст", "old"), "new")
|
||||||
|
self.assertEqual(got.count("tea:id"), 1)
|
||||||
|
self.assertEqual(gmap.id_in_body(got), "new")
|
||||||
|
|
||||||
|
def test_every_marker_is_removed_not_just_the_first(self):
|
||||||
|
"""A body hand-edited in the web UI could hold two. It comes back with
|
||||||
|
none, and the next push writes exactly one."""
|
||||||
|
mangled = ("<!-- tea:id one -->\n\nтекст\n\n<!-- tea:id two -->\nещё")
|
||||||
|
self.assertEqual(gmap.strip_id_marker(mangled), "текст\n\nещё")
|
||||||
|
self.assertEqual(gmap.with_id_marker(mangled, "one").count("tea:id"), 1)
|
||||||
|
|
||||||
|
def test_id_in_body_reads_the_first_marker(self):
|
||||||
|
self.assertEqual(gmap.id_in_body("<!-- tea:id one -->\n\nx"), "one")
|
||||||
|
self.assertIsNone(gmap.id_in_body("## Summary\nтекст"))
|
||||||
|
self.assertIsNone(gmap.id_in_body(""))
|
||||||
|
|
||||||
|
def test_a_marker_that_is_not_a_slug_is_ignored(self):
|
||||||
|
"""Better to fall back to the title than to name a file after junk."""
|
||||||
|
for junk in ("Not A Slug", "../etc/passwd", "-leading", "два-слова"):
|
||||||
|
self.assertIsNone(gmap.id_in_body("<!-- tea:id %s -->\n\nx" % junk))
|
||||||
|
|
||||||
|
def test_the_marker_tolerates_spacing(self):
|
||||||
|
self.assertEqual(gmap.id_in_body("<!--tea:id a-thing-->"), "a-thing")
|
||||||
|
self.assertEqual(gmap.id_in_body(" <!-- tea:id a-thing --> "),
|
||||||
|
"a-thing")
|
||||||
|
|
||||||
|
def test_a_marker_inside_prose_is_not_one(self):
|
||||||
|
"""Only a line that is nothing but the marker counts."""
|
||||||
|
self.assertIsNone(gmap.id_in_body("см. <!-- tea:id a-thing --> выше"))
|
||||||
|
|
||||||
|
def test_to_payload_marks_and_from_api_unmarks(self):
|
||||||
|
iss = issue.Issue(id="a-thing", title="A thing", body="## Summary\nтекст")
|
||||||
|
sent = gmap.to_payload(iss)["body"]
|
||||||
|
self.assertTrue(sent.startswith("<!-- tea:id a-thing -->"))
|
||||||
|
back, _ = gmap.from_api({"number": 1, "title": "A thing", "body": sent},
|
||||||
|
"a-thing", REPO)
|
||||||
|
self.assertEqual(back.body, "## Summary\nтекст")
|
||||||
|
|
||||||
|
|
||||||
|
class MarkerStaysOffDiskTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_the_local_file_never_holds_a_marker(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
self.assertIn("tea:id a-thing", self.fake.body_of(n))
|
||||||
|
|
||||||
|
self.run_pull(str(n))
|
||||||
|
with open(issue.path_of(self.root, "a-thing")) as f:
|
||||||
|
self.assertNotIn("tea:id", f.read())
|
||||||
|
|
||||||
|
def test_repeated_round_trips_do_not_accumulate_markers(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
for _ in range(3):
|
||||||
|
self.run_pull(str(n))
|
||||||
|
self.run_push("--update", "a-thing")
|
||||||
|
self.assertEqual(self.fake.body_of(n).count("tea:id"), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the round trip
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RoundTripTest(StoreTestCase):
|
||||||
|
"""push -> the file is gone -> pull -> the same file is back."""
|
||||||
|
|
||||||
|
def two_issues(self):
|
||||||
|
self.write_issue("first-thing", "First thing")
|
||||||
|
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||||
|
depends=["first-thing"])
|
||||||
|
|
||||||
|
def snapshot(self, id):
|
||||||
|
iss = issue.load(self.root, id)
|
||||||
|
return (iss.id, iss.title, iss.body, sorted(iss.depends),
|
||||||
|
sorted(iss.labels), iss.state)
|
||||||
|
|
||||||
|
def test_the_file_comes_back_identical(self):
|
||||||
|
self.two_issues()
|
||||||
|
before = self.snapshot("second-thing")
|
||||||
|
self.run_push()
|
||||||
|
self.assertGone("second-thing")
|
||||||
|
|
||||||
|
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||||
|
self.assertEqual(self.snapshot("second-thing"), before)
|
||||||
|
|
||||||
|
def test_depends_survives_the_round_trip(self):
|
||||||
|
"""The edge lives in Gitea's own graph while the files do not exist —
|
||||||
|
push wrote it, `pull --deps` reads it back, and the ledger turns the
|
||||||
|
number back into the slug it had here."""
|
||||||
|
self.two_issues()
|
||||||
|
self.run_push()
|
||||||
|
self.assertGone("first-thing")
|
||||||
|
self.assertGone("second-thing")
|
||||||
|
|
||||||
|
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||||
|
self.assertEqual(issue.load(self.root, "second-thing").depends,
|
||||||
|
["first-thing"])
|
||||||
|
|
||||||
|
def test_the_prose_dependency_is_still_the_authors_words(self):
|
||||||
|
self.two_issues()
|
||||||
|
self.run_push()
|
||||||
|
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||||
|
self.assertIn("- first-thing — ставит фундамент",
|
||||||
|
issue.load(self.root, "second-thing").body)
|
||||||
|
|
||||||
|
def test_a_rename_in_gitea_does_not_change_the_slug(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
|
||||||
|
self.fake.rename(n, "Completely different title now")
|
||||||
|
self.run_pull(str(n))
|
||||||
|
|
||||||
|
self.assertOnDisk("a-thing")
|
||||||
|
self.assertFalse(os.path.isfile(
|
||||||
|
issue.path_of(self.root, "completely-different-title-now")))
|
||||||
|
self.assertEqual(issue.load(self.root, "a-thing").title,
|
||||||
|
"Completely different title now")
|
||||||
|
|
||||||
|
def test_the_slug_survives_a_rename_with_the_ledger_thrown_away(self):
|
||||||
|
"""The case `.remote.json` cannot cover: a fresh clone, or another
|
||||||
|
machine. The marker is the only thing left, and it is enough."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
|
||||||
|
self.fake.rename(n, "Completely different title now")
|
||||||
|
os.remove(_gitea.map_path(self.root))
|
||||||
|
|
||||||
|
self.run_pull(str(n))
|
||||||
|
self.assertOnDisk("a-thing")
|
||||||
|
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
|
||||||
|
|
||||||
|
def test_depends_survives_a_lost_ledger_when_both_come_back(self):
|
||||||
|
self.two_issues()
|
||||||
|
self.run_push()
|
||||||
|
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||||
|
os.remove(_gitea.map_path(self.root))
|
||||||
|
|
||||||
|
self.run_pull(str(first), str(second), "--deps")
|
||||||
|
self.assertEqual(issue.load(self.root, "second-thing").depends,
|
||||||
|
["first-thing"])
|
||||||
|
|
||||||
|
def test_an_issue_filed_in_the_web_ui_still_gets_a_slug(self):
|
||||||
|
"""No marker, no ledger entry — the title is the fallback, as before."""
|
||||||
|
self.fake.store(500, "Filed in the web ui", "## Summary\nтекст")
|
||||||
|
self.run_pull("500")
|
||||||
|
self.assertOnDisk("filed-in-the-web-ui")
|
||||||
|
|
||||||
|
def test_a_marker_colliding_with_a_local_issue_does_not_overwrite_it(self):
|
||||||
|
"""A slug is only taken at its word when it is free."""
|
||||||
|
self.write_issue("a-thing", "A thing", body="## Summary\nмоя локальная")
|
||||||
|
self.fake.store(500, "Something else",
|
||||||
|
gmap.with_id_marker("## Summary\nчужая", "a-thing"))
|
||||||
|
self.run_pull("500")
|
||||||
|
|
||||||
|
self.assertIn("моя локальная", issue.load(self.root, "a-thing").body)
|
||||||
|
self.assertIn("чужая", issue.load(self.root, "a-thing-2").body)
|
||||||
|
|
||||||
|
def test_the_branch_ref_comes_back_with_the_issue(self):
|
||||||
|
"""`branch:` is not written back to a file that is being deleted; it
|
||||||
|
goes up in the payload and comes down again on the next pull."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
self.run_pull(str(n))
|
||||||
|
self.assertEqual(issue.load(self.root, "a-thing").extra.get("branch"),
|
||||||
|
"test-branch")
|
||||||
|
|
||||||
|
def test_pushing_the_pulled_copy_back_is_a_no_op_on_the_body(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.run_push()
|
||||||
|
n = self.number_of("a-thing")
|
||||||
|
self.run_pull(str(n))
|
||||||
|
before = self.fake.body_of(n)
|
||||||
|
|
||||||
|
self.run_push("--update", "a-thing")
|
||||||
|
self.assertEqual(self.fake.body_of(n), before)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the ledger
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class StoreListingTest(StoreTestCase):
|
||||||
|
"""The store layout the drop depends on."""
|
||||||
|
|
||||||
|
def test_a_comment_thread_is_not_an_issue(self):
|
||||||
|
"""`<id>.comments.md` sits in the store beside the issue. A slug has no
|
||||||
|
dot in it, so it is not a slug and not a unit of work — otherwise a bare
|
||||||
|
`push.py` files the comment thread as an issue of its own."""
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.write_comments("a-thing")
|
||||||
|
self.assertEqual(issue.all_ids(self.root), ["a-thing"])
|
||||||
|
|
||||||
|
def test_a_bare_push_with_threads_in_the_store_still_works(self):
|
||||||
|
self.write_issue("a-thing", "A thing")
|
||||||
|
self.write_comments("a-thing")
|
||||||
|
self.run_push()
|
||||||
|
self.assertGone("a-thing")
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerTest(StoreTestCase):
|
||||||
|
"""`.remote.json` after the files it used to index are gone."""
|
||||||
|
|
||||||
|
def test_rebuild_keeps_entries_whose_files_no_longer_exist(self):
|
||||||
|
"""It used to reconstruct the map from the files and save the result,
|
||||||
|
which would now silently drop every pushed issue."""
|
||||||
|
_gitea.save_map(self.root, {gmap.remote_key(REPO, 7): "gone-thing"})
|
||||||
|
self.write_issue("here-thing", "Here thing", origin="gitea",
|
||||||
|
extra={"gitea": gmap.remote_key(REPO, 8)})
|
||||||
|
|
||||||
|
got = _gitea.rebuild_map(self.root, issue.load_all(self.root))
|
||||||
|
self.assertEqual(got, {gmap.remote_key(REPO, 7): "gone-thing",
|
||||||
|
gmap.remote_key(REPO, 8): "here-thing"})
|
||||||
|
self.assertEqual(_gitea.load_map(self.root), got)
|
||||||
|
|
||||||
|
def test_a_second_push_reuses_the_ledger_not_the_files(self):
|
||||||
|
"""Two pushes, no pull in between for the blocker: its file is gone, so
|
||||||
|
its number can only come from the ledger — and the link is still made."""
|
||||||
|
self.write_issue("first-thing", "First thing")
|
||||||
|
self.run_push("first-thing")
|
||||||
|
self.assertGone("first-thing")
|
||||||
|
|
||||||
|
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||||
|
depends=["first-thing"])
|
||||||
|
out, err = self.run_push("second-thing")
|
||||||
|
|
||||||
|
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||||
|
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
|
||||||
|
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
|
||||||
|
self.assertNotIn("local-only", err)
|
||||||
|
|
||||||
|
def test_the_dry_run_resolves_a_dropped_blocker_from_the_ledger(self):
|
||||||
|
self.write_issue("first-thing", "First thing")
|
||||||
|
self.run_push("first-thing")
|
||||||
|
first = self.number_of("first-thing")
|
||||||
|
|
||||||
|
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||||
|
depends=["first-thing"])
|
||||||
|
out, _ = self.run_push("--dry-run", "second-thing")
|
||||||
|
self.assertIn("link -> %s#%d (first-thing)" % (REPO, first), out)
|
||||||
|
|
||||||
|
def test_ledger_keys_prefers_the_current_repo(self):
|
||||||
|
m = {"other/repo#7": "a-thing", "%s#9" % REPO: "a-thing"}
|
||||||
|
self.assertEqual(push.ledger_keys(m, REPO), {"a-thing": "%s#9" % REPO})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
How a directory of markdown becomes a page tree, and that the tree survives a
|
||||||
|
round trip through the wiki layer's bookkeeping.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
Stdlib unittest, no third-party anything. `skills/*/scripts/` are not packages,
|
||||||
|
so the modules under test are imported by path.
|
||||||
|
|
||||||
|
Nothing here touches tmp/wiki/. The subprocess cases build a throwaway
|
||||||
|
repository in a temp directory — a `.git` marker, a copy of both script layers,
|
||||||
|
a directory of fixture artifacts — and run the real scripts inside it. That is
|
||||||
|
the only honest way to test behaviour that depends on where a script is run
|
||||||
|
from, and it keeps the developer's own cache out of the blast radius.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
PAGE_SCRIPTS = os.path.join(REPO, "skills", "page", "scripts")
|
||||||
|
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
|
||||||
|
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||||
|
|
||||||
|
sys.path.insert(0, PAGE_SCRIPTS)
|
||||||
|
sys.path.insert(0, WIKI_SCRIPTS)
|
||||||
|
import page # noqa: E402
|
||||||
|
import wikimap # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# The fixture mirrors the shape a real discussion leaves behind: numbered files
|
||||||
|
# for ordering, a `00-` file standing in for its directory, headings that no
|
||||||
|
# mechanical rule could derive from the file names.
|
||||||
|
FIXTURE = {
|
||||||
|
"handoff.md": "# handoff — notification chains\n\nEntry point.\n",
|
||||||
|
"ideas/00-intro.md": "# Ideas for chain business requirements\n\nFlat list.\n",
|
||||||
|
"ideas/02-chain-core.md": "## Chain core\n\n- **B-01.** Something.\n",
|
||||||
|
"ideas/01-relations.md": "## Relations\n\nHow they relate.\n",
|
||||||
|
"questions/00-intro.md": "# Questions\n\nOpen questions.\n",
|
||||||
|
"questions/03-q-01-do-we-know-the-participant.md":
|
||||||
|
"## Q-01. Do We Know the Chain Participant by Name\n\n**Question.** …\n",
|
||||||
|
"notes/plain.md": "No heading here, only prose.\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_artifacts(root):
|
||||||
|
for rel, text in FIXTURE.items():
|
||||||
|
p = os.path.join(root, rel.replace("/", os.sep))
|
||||||
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||||
|
with open(p, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# names, titles, order — pure
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNames(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_order_comes_from_a_numeric_prefix(self):
|
||||||
|
self.assertEqual(page.order_of("02-chain-core.md"), 2)
|
||||||
|
self.assertEqual(page.order_of("00-intro.md"), 0)
|
||||||
|
self.assertIsNone(page.order_of("handoff.md"))
|
||||||
|
|
||||||
|
def test_zero_is_an_order_and_not_a_missing_one(self):
|
||||||
|
"""`00-` means "this is the directory's own page", so the difference
|
||||||
|
between 0 and None decides where a page lands in the tree."""
|
||||||
|
self.assertIsNot(page.order_of("00-intro.md"), None)
|
||||||
|
|
||||||
|
def test_the_prefix_never_reaches_the_title(self):
|
||||||
|
self.assertEqual(page.title_from_name("02-chain-core.md"), "Chain core")
|
||||||
|
|
||||||
|
def test_only_the_first_letter_is_raised(self):
|
||||||
|
"""Title-casing would wreck every name that already knows how it is
|
||||||
|
spelled."""
|
||||||
|
self.assertEqual(page.title_from_name("sqlc-and-APNs.md"), "Sqlc and APNs")
|
||||||
|
|
||||||
|
def test_a_heading_beats_a_file_name(self):
|
||||||
|
text = "## Q-01. Do We Know the Chain Participant by Name\n"
|
||||||
|
self.assertEqual(page.title_from_body(text),
|
||||||
|
"Q-01. Do We Know the Chain Participant by Name")
|
||||||
|
|
||||||
|
def test_only_the_first_heading_counts(self):
|
||||||
|
self.assertEqual(page.title_from_body("# One\n\n## Two\n"), "One")
|
||||||
|
|
||||||
|
def test_a_heading_after_prose_is_a_section_not_a_name(self):
|
||||||
|
self.assertIsNone(page.title_from_body("Prose first.\n\n# Late\n"))
|
||||||
|
|
||||||
|
def test_markup_is_stripped_from_a_title(self):
|
||||||
|
"""A page list does not render markdown, so inline code in a heading is
|
||||||
|
noise in the name."""
|
||||||
|
self.assertEqual(page.sanitize_title("Inventory — `P-NN`"),
|
||||||
|
"Inventory — P-NN")
|
||||||
|
|
||||||
|
def test_a_slash_in_a_heading_does_not_invent_hierarchy(self):
|
||||||
|
self.assertEqual(page.sanitize_title("Send/receive timing"),
|
||||||
|
"Send-receive timing")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaths(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_a_title_becomes_one_path_component_per_segment(self):
|
||||||
|
self.assertEqual(page.path_for_title("Simple Chains/Ideas/Chain core"),
|
||||||
|
os.path.join("Simple-Chains", "Ideas", "Chain-core.md"))
|
||||||
|
|
||||||
|
def test_shell_hostile_characters_leave_the_path_but_not_the_title(self):
|
||||||
|
title = "Simple Chains/Don't send to this one"
|
||||||
|
self.assertEqual(page.path_for_title(title),
|
||||||
|
os.path.join("Simple-Chains", "Dont-send-to-this-one.md"))
|
||||||
|
self.assertIn("'", title)
|
||||||
|
|
||||||
|
def test_an_empty_title_still_produces_a_file(self):
|
||||||
|
self.assertEqual(page.path_for_title(""), "untitled.md")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# importing
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPlanImport(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.src = build_artifacts(os.path.join(self.tmp.name, "artifacts"))
|
||||||
|
self.pages, self.collisions = page.plan_import(self.src, "Simple Chains")
|
||||||
|
self.titles = {p["title"] for p in self.pages}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_nothing_collides(self):
|
||||||
|
self.assertEqual(self.collisions, [])
|
||||||
|
|
||||||
|
def test_an_order_zero_file_becomes_the_directorys_own_page(self):
|
||||||
|
self.assertIn("Simple Chains/Ideas", self.titles)
|
||||||
|
|
||||||
|
def test_that_page_is_named_for_the_directory_not_its_heading(self):
|
||||||
|
"""`ideas/00-intro.md` opens with "Ideas for chain business
|
||||||
|
requirements". A child's title must extend its parent's exactly, and no
|
||||||
|
child would ever be prefixed by that."""
|
||||||
|
self.assertNotIn("Simple Chains/Ideas for chain business requirements",
|
||||||
|
self.titles)
|
||||||
|
|
||||||
|
def test_every_child_extends_its_parents_title(self):
|
||||||
|
self.assertIn("Simple Chains/Ideas/Chain core", self.titles)
|
||||||
|
self.assertIn("Simple Chains/Questions/"
|
||||||
|
"Q-01. Do We Know the Chain Participant by Name",
|
||||||
|
self.titles)
|
||||||
|
|
||||||
|
def test_a_file_without_a_heading_falls_back_to_its_name(self):
|
||||||
|
self.assertIn("Simple Chains/Notes/Plain", self.titles)
|
||||||
|
|
||||||
|
def test_the_prefix_hangs_everything_under_one_title(self):
|
||||||
|
self.assertTrue(all(t.startswith("Simple Chains/") for t in self.titles))
|
||||||
|
|
||||||
|
def test_numeric_prefixes_order_siblings(self):
|
||||||
|
ideas = [p for p in self.pages
|
||||||
|
if p["title"].startswith("Simple Chains/Ideas/")]
|
||||||
|
self.assertEqual([p["title"].split("/")[-1] for p in ideas],
|
||||||
|
["Relations", "Chain core"])
|
||||||
|
|
||||||
|
def test_a_collision_is_reported_and_not_resolved(self):
|
||||||
|
"""Two headings that sanitize to one path. Picking a winner is how a
|
||||||
|
discussion loses a document."""
|
||||||
|
d = os.path.join(self.tmp.name, "clash")
|
||||||
|
os.makedirs(d)
|
||||||
|
for name, heading in (("a.md", "# Send timing"), ("b.md", "# Send/timing")):
|
||||||
|
with open(os.path.join(d, name), "w") as f:
|
||||||
|
f.write(heading + "\n")
|
||||||
|
_, collisions = page.plan_import(d)
|
||||||
|
self.assertEqual(len(collisions), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the index
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIndex(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.m = page.blank_manifest("s")
|
||||||
|
for title, order in (("Top", None),
|
||||||
|
("Top/Ideas", 0),
|
||||||
|
("Top/Ideas/Relations", 1),
|
||||||
|
("Top/Ideas/Chain core", 2),
|
||||||
|
("Top/Zeta", None),
|
||||||
|
("Top/Parked/Decisions", None)):
|
||||||
|
self.m["pages"][page.path_for_title(title)] = page.entry(title, order)
|
||||||
|
|
||||||
|
def test_nesting_follows_titles_not_manifest_path_order(self):
|
||||||
|
"""On disk `Top/Zeta.md` sorts before `Top/Ideas/Chain-core.md`; in the
|
||||||
|
hierarchy Zeta is a child and Chain core a grandchild."""
|
||||||
|
body = page.render_index(self.m, "Top")
|
||||||
|
lines = [l for l in body.splitlines() if l.strip().startswith("- ")
|
||||||
|
or l.strip().startswith(" - ")]
|
||||||
|
ideas = next(i for i, l in enumerate(lines) if "|Ideas]]" in l)
|
||||||
|
core = next(i for i, l in enumerate(lines) if "Chain core]]" in l)
|
||||||
|
zeta = next(i for i, l in enumerate(lines) if "|Zeta]]" in l)
|
||||||
|
self.assertLess(ideas, core)
|
||||||
|
self.assertLess(core, zeta)
|
||||||
|
|
||||||
|
def test_a_parent_with_no_page_still_holds_its_children(self):
|
||||||
|
"""Nothing is published at `Top/Parked`; dropping it would hide
|
||||||
|
Decisions entirely."""
|
||||||
|
body = page.render_index(self.m, "Top")
|
||||||
|
self.assertIn("- [[Top/Parked|Parked]]", body)
|
||||||
|
self.assertIn(" - [[Top/Parked/Decisions|Decisions]]", body)
|
||||||
|
|
||||||
|
def test_an_unpublished_page_is_linked_by_wiki_syntax(self):
|
||||||
|
self.assertIn("[[Top/Ideas|Ideas]]", page.render_index(self.m, "Top"))
|
||||||
|
|
||||||
|
def test_a_published_page_is_linked_by_its_sub_url(self):
|
||||||
|
"""sub_url is the only address Gitea guarantees, and it appears only
|
||||||
|
after a push — so rebuilding the index after publishing upgrades the
|
||||||
|
links."""
|
||||||
|
rel = page.path_for_title("Top/Ideas")
|
||||||
|
self.m["pages"][rel]["sub_url"] = "Top%2FIdeas"
|
||||||
|
self.assertIn("- [Ideas](Top%2FIdeas)", page.render_index(self.m, "Top"))
|
||||||
|
|
||||||
|
def test_the_prefix_itself_is_not_listed_inside_its_own_index(self):
|
||||||
|
self.assertNotIn("|Top]]", page.render_index(self.m, "Top"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the manifest
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestManifest(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_a_missing_manifest_loads_blank(self):
|
||||||
|
m = page.load_manifest("a/b", self.tmp.name)
|
||||||
|
self.assertEqual(m["pages"], {})
|
||||||
|
|
||||||
|
def test_wiki_bookkeeping_survives_a_round_trip(self):
|
||||||
|
"""The domain never reads sub_url, and must never drop it either — a
|
||||||
|
lost sub_url is a duplicate page on the next push."""
|
||||||
|
m = page.blank_manifest("a/b")
|
||||||
|
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X", pushed="deadbeef")
|
||||||
|
page.save_manifest(m, self.tmp.name)
|
||||||
|
back = page.load_manifest("a/b", self.tmp.name)
|
||||||
|
self.assertEqual(back["pages"]["X.md"]["sub_url"], "X")
|
||||||
|
self.assertEqual(back["pages"]["X.md"]["pushed"], "deadbeef")
|
||||||
|
self.assertEqual(back["pages"]["X.md"]["order"], 1)
|
||||||
|
|
||||||
|
def test_domain_keys_are_written_first(self):
|
||||||
|
"""The manifest lands in a diff on every sync; a readable one gets
|
||||||
|
checked."""
|
||||||
|
m = page.blank_manifest("a/b")
|
||||||
|
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X")
|
||||||
|
with open(page.save_manifest(m, self.tmp.name), encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
self.assertLess(raw.index('"title"'), raw.index('"sub_url"'))
|
||||||
|
|
||||||
|
def test_children_of_is_a_prefix_test_and_not_a_substring_one(self):
|
||||||
|
m = page.blank_manifest("s")
|
||||||
|
for t in ("Top", "Top/A", "Topaz", "Topaz/B"):
|
||||||
|
m["pages"][page.path_for_title(t)] = page.entry(t)
|
||||||
|
got = {e["title"] for _, e in page.children_of(m, "Top")}
|
||||||
|
self.assertEqual(got, {"Top", "Top/A"})
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# md <-> wiki JSON
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestWikiMap(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_a_body_survives_encode_and_decode(self):
|
||||||
|
text = "# Заголовок — DC\n\n- [x] пункт\n"
|
||||||
|
self.assertEqual(wikimap.decode({"content_base64": wikimap.encode(text)}),
|
||||||
|
text)
|
||||||
|
|
||||||
|
def test_an_empty_page_decodes_to_an_empty_string(self):
|
||||||
|
"""A page that exists with no body is a real state; the caller writing
|
||||||
|
a file should not have to tell it from a missing key."""
|
||||||
|
self.assertEqual(wikimap.decode({}), "")
|
||||||
|
self.assertEqual(wikimap.decode({"content_base64": None}), "")
|
||||||
|
|
||||||
|
def test_from_payload_takes_the_address_gitea_returned(self):
|
||||||
|
got = wikimap.from_payload({
|
||||||
|
"title": "A/B", "sub_url": "A%2FB.-", "html_url": "https://x/A%2FB.-",
|
||||||
|
"last_commit": {"sha": "abc", "author": {"date": "2026-08-10T11:15:39Z"}},
|
||||||
|
})
|
||||||
|
self.assertEqual(got["sub_url"], "A%2FB.-")
|
||||||
|
self.assertEqual(got["sha"], "abc")
|
||||||
|
self.assertEqual(got["remote-updated"], "2026-08-10T11:15:39Z")
|
||||||
|
|
||||||
|
def test_a_sub_url_goes_into_the_endpoint_verbatim(self):
|
||||||
|
"""Gitea hands it back already escaped; re-encoding it produces a path
|
||||||
|
that resolves to nothing."""
|
||||||
|
self.assertEqual(
|
||||||
|
wikimap.page_endpoint("repos/o/r", "A%2FB.-"),
|
||||||
|
"repos/o/r/wiki/page/A%2FB.-")
|
||||||
|
|
||||||
|
def test_prefix_matching_needs_a_separator(self):
|
||||||
|
self.assertTrue(wikimap.matches_prefix("Top", "Top"))
|
||||||
|
self.assertTrue(wikimap.matches_prefix("Top/A", "Top"))
|
||||||
|
self.assertFalse(wikimap.matches_prefix("Topaz", "Top"))
|
||||||
|
|
||||||
|
def test_an_empty_prefix_matches_everything(self):
|
||||||
|
self.assertTrue(wikimap.matches_prefix("anything", ""))
|
||||||
|
|
||||||
|
def test_a_payload_carries_the_operators_message(self):
|
||||||
|
p = wikimap.new_payload("A/B", "body", "why it changed")
|
||||||
|
self.assertEqual(p["message"], "why it changed")
|
||||||
|
self.assertEqual(wikimap.decode(p), "body")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the scripts, in a throwaway repository
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestImportScript(unittest.TestCase):
|
||||||
|
"""The real scripts, run as subprocesses inside a scratch repo."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = self.tmp.name
|
||||||
|
os.makedirs(os.path.join(self.root, ".git"))
|
||||||
|
for layer in ("page", "wiki"):
|
||||||
|
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
||||||
|
os.path.join(self.root, "skills", layer, "scripts"),
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__"))
|
||||||
|
shutil.copytree(SYNC_SCRIPTS,
|
||||||
|
os.path.join(self.root, "skills", "sync", "scripts"),
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__"))
|
||||||
|
self.src = build_artifacts(os.path.join(self.root, "artifacts"))
|
||||||
|
self.scripts = os.path.join(self.root, "skills", "page", "scripts")
|
||||||
|
self.space = os.path.join(self.root, "tmp", "wiki", "s")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def run_script(self, name, *args, cwd=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, os.path.join(self.scripts, name)] + list(args),
|
||||||
|
capture_output=True, text=True, cwd=cwd or self.root)
|
||||||
|
|
||||||
|
def manifest(self):
|
||||||
|
with open(os.path.join(self.space, ".pages.json"), encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def do_import(self, *extra):
|
||||||
|
return self.run_script("page_import.py", "--from", self.src,
|
||||||
|
"--space", "s", "--prefix", "Top", *extra)
|
||||||
|
|
||||||
|
def test_dry_run_writes_nothing(self):
|
||||||
|
r = self.do_import("--dry-run")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
self.assertFalse(os.path.exists(self.space))
|
||||||
|
|
||||||
|
def test_import_writes_the_tree_and_the_manifest(self):
|
||||||
|
self.assertEqual(self.do_import().returncode, 0)
|
||||||
|
self.assertTrue(os.path.isfile(
|
||||||
|
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
|
||||||
|
self.assertIn("Top/Ideas/Chain core",
|
||||||
|
{e["title"] for e in self.manifest()["pages"].values()})
|
||||||
|
|
||||||
|
def test_creating_a_space_is_announced(self):
|
||||||
|
"""Nothing creates a store as a silent side effect of a write — that is
|
||||||
|
how a typo in --space makes a second one nobody notices."""
|
||||||
|
self.assertIn("created space", self.do_import().stderr)
|
||||||
|
|
||||||
|
def test_the_cache_is_found_from_a_subdirectory(self):
|
||||||
|
"""The anchor is the script's own location, not cwd. A `cd` outlives
|
||||||
|
the command that ran it."""
|
||||||
|
self.do_import()
|
||||||
|
deep = os.path.join(self.src, "ideas")
|
||||||
|
r = self.run_script("page_ls.py", "--space", "s", cwd=deep)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
self.assertIn("Chain core", r.stdout)
|
||||||
|
|
||||||
|
def test_a_reimport_keeps_the_title_and_the_wiki_bookkeeping(self):
|
||||||
|
self.do_import()
|
||||||
|
m = self.manifest()
|
||||||
|
rel = "Top/Ideas/Chain-core.md"
|
||||||
|
m["pages"][rel]["sub_url"] = "Top%2FIdeas%2FChain-core"
|
||||||
|
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
||||||
|
json.dump(m, f)
|
||||||
|
|
||||||
|
# The heading changes. Without the manifest that would rename a
|
||||||
|
# published page, which does not rename it — it publishes a second one.
|
||||||
|
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
|
||||||
|
f.write("## A completely different heading\n\nchanged\n")
|
||||||
|
self.do_import()
|
||||||
|
|
||||||
|
after = self.manifest()["pages"][rel]
|
||||||
|
self.assertEqual(after["title"], "Top/Ideas/Chain core")
|
||||||
|
self.assertEqual(after["sub_url"], "Top%2FIdeas%2FChain-core")
|
||||||
|
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
|
||||||
|
self.assertIn("A completely different heading", f.read())
|
||||||
|
|
||||||
|
def test_retitle_moves_the_page_and_keeps_its_address(self):
|
||||||
|
"""A retitle changes the path, so the entry has to be found by source.
|
||||||
|
Found by path it would look new, and the next push would publish a
|
||||||
|
duplicate beside the page it was meant to rename."""
|
||||||
|
self.do_import()
|
||||||
|
m = self.manifest()
|
||||||
|
m["pages"]["Top/Ideas/Chain-core.md"]["sub_url"] = "Top%2FIdeas%2FChain-core"
|
||||||
|
m["pages"]["Top/Ideas/Chain-core.md"]["pushed"] = "deadbeef"
|
||||||
|
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
||||||
|
json.dump(m, f)
|
||||||
|
|
||||||
|
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
|
||||||
|
f.write("## Chain core, renamed\n")
|
||||||
|
self.do_import("--retitle")
|
||||||
|
|
||||||
|
pages = self.manifest()["pages"]
|
||||||
|
self.assertNotIn("Top/Ideas/Chain-core.md", pages)
|
||||||
|
moved = pages["Top/Ideas/Chain-core-renamed.md"]
|
||||||
|
self.assertEqual(moved["title"], "Top/Ideas/Chain core, renamed")
|
||||||
|
self.assertEqual(moved["sub_url"], "Top%2FIdeas%2FChain-core")
|
||||||
|
self.assertFalse(os.path.exists(
|
||||||
|
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
|
||||||
|
|
||||||
|
def test_a_rename_makes_the_next_push_send_the_page(self):
|
||||||
|
"""The body can be byte-identical after a rename, and push decides by
|
||||||
|
body hash alone — so a stale `pushed` would skip the rename forever."""
|
||||||
|
self.do_import()
|
||||||
|
m = self.manifest()
|
||||||
|
rel = "Top/Ideas/Chain-core.md"
|
||||||
|
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
|
||||||
|
body = f.read()
|
||||||
|
m["pages"][rel]["sub_url"] = "x"
|
||||||
|
m["pages"][rel]["pushed"] = __import__("hashlib").sha1(
|
||||||
|
body.encode()).hexdigest()
|
||||||
|
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
||||||
|
json.dump(m, f)
|
||||||
|
|
||||||
|
src = os.path.join(self.src, "ideas", "02-chain-core.md")
|
||||||
|
with open(src, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
with open(src, "w") as f:
|
||||||
|
f.write(text.replace("## Chain core", "## Chain core renamed"))
|
||||||
|
self.do_import("--retitle")
|
||||||
|
|
||||||
|
moved = self.manifest()["pages"]["Top/Ideas/Chain-core-renamed.md"]
|
||||||
|
self.assertNotIn("pushed", moved)
|
||||||
|
|
||||||
|
def test_ls_reports_an_unpublished_page_as_local(self):
|
||||||
|
self.do_import()
|
||||||
|
r = self.run_script("page_ls.py", "--space", "s")
|
||||||
|
self.assertIn("local", r.stdout)
|
||||||
|
self.assertNotIn("synced", r.stdout)
|
||||||
|
|
||||||
|
def test_ls_distinguishes_a_missing_space_from_an_empty_one(self):
|
||||||
|
r = self.run_script("page_ls.py", "--space", "nope")
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertIn("no such space", r.stderr)
|
||||||
|
|
||||||
|
def test_index_is_written_as_an_ordinary_page(self):
|
||||||
|
self.do_import()
|
||||||
|
r = self.run_script("page_index.py", "--space", "s", "--prefix", "Top")
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
self.assertIn("Top.md", self.manifest()["pages"])
|
||||||
|
with open(os.path.join(self.space, "Top.md"), encoding="utf-8") as f:
|
||||||
|
body = f.read()
|
||||||
|
self.assertIn("- [[Top/Ideas|Ideas]]", body)
|
||||||
|
self.assertIn(" - [[Top/Ideas/Chain core|Chain core]]", body)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the layering rule, mechanically
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLayering(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_the_page_layer_is_stdlib_only(self):
|
||||||
|
"""skills/page must keep working with skills/wiki deleted — so no
|
||||||
|
transport, and above all no subprocess, in the domain layer."""
|
||||||
|
imported = set()
|
||||||
|
for name in sorted(os.listdir(PAGE_SCRIPTS)):
|
||||||
|
if not name.endswith(".py"):
|
||||||
|
continue
|
||||||
|
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith(("import ", "from ")):
|
||||||
|
imported.add(line.split()[1].split(".")[0])
|
||||||
|
foreign = imported - {"page"} - sys.stdlib_module_names
|
||||||
|
self.assertEqual(foreign, set(),
|
||||||
|
"non-stdlib import in the page layer: %s"
|
||||||
|
% ", ".join(sorted(foreign)))
|
||||||
|
self.assertNotIn("subprocess", imported)
|
||||||
|
|
||||||
|
def test_the_page_layer_never_mentions_a_tracker(self):
|
||||||
|
"""A sub_url, a login, an HTTP verb in skills/page means the concept is
|
||||||
|
in the wrong layer."""
|
||||||
|
banned = ("tea api", "_gitea", "GITEA_LOGIN", "content_base64")
|
||||||
|
for name in sorted(os.listdir(PAGE_SCRIPTS)):
|
||||||
|
if not name.endswith(".py"):
|
||||||
|
continue
|
||||||
|
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
|
||||||
|
body = f.read()
|
||||||
|
for word in banned:
|
||||||
|
self.assertNotIn(word, body,
|
||||||
|
"%s mentions %r" % (name, word))
|
||||||
|
|
||||||
|
def test_wikimap_is_pure(self):
|
||||||
|
"""The translation layer holds no transport and no I/O: give it a
|
||||||
|
payload, get a page; give it a page, get a request body. Checked on the
|
||||||
|
imports, not on the prose — the docstring names the things it refuses
|
||||||
|
to do."""
|
||||||
|
with open(os.path.join(WIKI_SCRIPTS, "wikimap.py")) as f:
|
||||||
|
imported = {line.split()[1].split(".")[0] for line in f
|
||||||
|
if line.startswith(("import ", "from "))}
|
||||||
|
self.assertEqual(imported, {"base64"},
|
||||||
|
"wikimap.py imports more than the translation needs")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Native Gitea dependency links, written by push.py.
|
||||||
|
|
||||||
|
The transport is stubbed at exactly one seam — `_gitea.api`, the single
|
||||||
|
function that shells out to `tea` — so everything above it runs for real:
|
||||||
|
argument parsing, validation, topological order, the id map, map.py's payload
|
||||||
|
shapes and _gitea's own endpoint/body construction. Nothing here touches a
|
||||||
|
network, and no test may ever be made to.
|
||||||
|
|
||||||
|
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||||
|
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|
||||||
|
import _gitea # noqa: E402
|
||||||
|
import issue # noqa: E402
|
||||||
|
import map as gmap # noqa: E402
|
||||||
|
import push # noqa: E402
|
||||||
|
|
||||||
|
REPO = "claude-skills/tea"
|
||||||
|
BASE = "repos/%s" % REPO
|
||||||
|
LABELS = {"type/task": 901, "type/bug": 902, "severity/medium": 903,
|
||||||
|
"comp/sync": 904}
|
||||||
|
LABEL_NAMES = {v: k for k, v in LABELS.items()}
|
||||||
|
|
||||||
|
BODY = """## Summary
|
||||||
|
Прозаическое описание.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
skills/issue/references/format.md
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
- first-thing — ставит фундамент, без него второй не собрать
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] что-нибудь работает
|
||||||
|
"""
|
||||||
|
|
||||||
|
BODY_NO_DEPS = """## Summary
|
||||||
|
Прозаическое описание.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
skills/issue/references/format.md
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] что-нибудь работает
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGitea(object):
|
||||||
|
"""A `tea api` that answers from memory and remembers what it was asked.
|
||||||
|
|
||||||
|
Dependency links are kept the way Gitea keeps them: per blocked issue, a
|
||||||
|
set of (repo, number) blockers. That is what makes the idempotence test
|
||||||
|
meaningful — the second push sees the link the first one made."""
|
||||||
|
|
||||||
|
def __init__(self, next_number=101):
|
||||||
|
self.calls = [] # (method, endpoint, payload)
|
||||||
|
self.next_number = next_number
|
||||||
|
self.deps = {} # number -> {(repo, number)}
|
||||||
|
self.titles = {} # number -> title
|
||||||
|
self.fail_dependency_post = False
|
||||||
|
|
||||||
|
# -- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def writes(self):
|
||||||
|
"""Every non-GET call. `--dry-run` must produce an empty list."""
|
||||||
|
return [c for c in self.calls if c[0] != "GET"]
|
||||||
|
|
||||||
|
def dep_posts(self):
|
||||||
|
return [c for c in self.calls
|
||||||
|
if c[0] == "POST" and c[1].endswith("/dependencies")]
|
||||||
|
|
||||||
|
def issue_payload(self, number, labels=()):
|
||||||
|
return {"number": number,
|
||||||
|
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||||
|
"title": self.titles.get(number, ""),
|
||||||
|
"labels": [{"name": LABEL_NAMES[i]} for i in labels
|
||||||
|
if i in LABEL_NAMES],
|
||||||
|
"updated_at": "2026-08-10T00:00:00Z",
|
||||||
|
"repository": {"full_name": REPO}}
|
||||||
|
|
||||||
|
# -- the seam ----------------------------------------------------------
|
||||||
|
|
||||||
|
def api(self, login, endpoint, method="GET", payload=None,
|
||||||
|
payload_name=None, out_root=None, allow_fail=False):
|
||||||
|
self.calls.append((method, endpoint, payload))
|
||||||
|
path = endpoint.split("?")[0]
|
||||||
|
|
||||||
|
if path == "%s/labels" % BASE and method == "GET":
|
||||||
|
# Every label the run could ask for, so nothing is ever created.
|
||||||
|
return [{"name": n, "id": i} for n, i in LABELS.items()]
|
||||||
|
|
||||||
|
if path == "%s/issues" % BASE and method == "POST":
|
||||||
|
number = self.next_number
|
||||||
|
self.next_number += 1
|
||||||
|
self.titles[number] = (payload or {}).get("title", "")
|
||||||
|
# Echo the labels back, or push re-applies them with a PUT.
|
||||||
|
return self.issue_payload(number, (payload or {}).get("labels") or [])
|
||||||
|
|
||||||
|
if path.endswith("/dependencies"):
|
||||||
|
number = int(path.split("/issues/")[1].split("/")[0])
|
||||||
|
if method == "GET":
|
||||||
|
return [dict(self.issue_payload(n), repository={"full_name": r})
|
||||||
|
for r, n in sorted(self.deps.get(number, set()))]
|
||||||
|
if method == "POST":
|
||||||
|
if self.fail_dependency_post:
|
||||||
|
return None
|
||||||
|
key = ("%s/%s" % (payload["owner"], payload["repo"]),
|
||||||
|
int(payload["index"]))
|
||||||
|
self.deps.setdefault(number, set()).add(key)
|
||||||
|
return self.issue_payload(number)
|
||||||
|
|
||||||
|
if "/issues/" in path and method == "PATCH":
|
||||||
|
number = int(path.rsplit("/", 1)[1])
|
||||||
|
self.titles[number] = (payload or {}).get("title", self.titles.get(number, ""))
|
||||||
|
return self.issue_payload(number, (payload or {}).get("labels") or [])
|
||||||
|
|
||||||
|
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||||
|
|
||||||
|
|
||||||
|
class PushTestCase(unittest.TestCase):
|
||||||
|
"""A temp store, a fake transport, and no git."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
||||||
|
self.fake = FakeGitea()
|
||||||
|
patches = [
|
||||||
|
mock.patch.object(_gitea, "api", self.fake.api),
|
||||||
|
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
|
||||||
|
# push reads the current branch from git; a temp store has none and
|
||||||
|
# the runner's branch would leak into the payload.
|
||||||
|
mock.patch.object(push, "git_branch", lambda: "test-branch"),
|
||||||
|
]
|
||||||
|
for p in patches:
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, True)
|
||||||
|
|
||||||
|
# -- fixtures ----------------------------------------------------------
|
||||||
|
|
||||||
|
def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None,
|
||||||
|
origin=issue.LOCAL):
|
||||||
|
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
||||||
|
depends=list(depends), origin=origin,
|
||||||
|
extra=dict(extra or {}))
|
||||||
|
issue.save(self.root, iss)
|
||||||
|
return iss
|
||||||
|
|
||||||
|
def repull(self, id, body=BODY_NO_DEPS, depends=()):
|
||||||
|
"""Put a pushed issue back the way `pull.py` would.
|
||||||
|
|
||||||
|
Push deletes the file, so anything that pushes the same issue twice has
|
||||||
|
to fetch it in between — which is the workflow, not a test artifact.
|
||||||
|
The slug and the number come from the ledger, exactly as `pull.id_for`
|
||||||
|
would resolve them."""
|
||||||
|
number = self.number_of(id)
|
||||||
|
self.assertIsNotNone(number, "%s was never pushed" % id)
|
||||||
|
return self.write_issue(id, self.fake.titles[number], body=body,
|
||||||
|
depends=depends, origin="gitea",
|
||||||
|
extra={"gitea": "%s#%d" % (REPO, number)})
|
||||||
|
|
||||||
|
def two_issues(self):
|
||||||
|
"""first-thing, and second-thing which depends on it."""
|
||||||
|
self.write_issue("first-thing", "First thing")
|
||||||
|
self.write_issue("second-thing", "Second thing", body=BODY,
|
||||||
|
depends=["first-thing"])
|
||||||
|
|
||||||
|
def run_push(self, *argv):
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
args = ["push.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(out), \
|
||||||
|
contextlib.redirect_stderr(err):
|
||||||
|
push.main()
|
||||||
|
return out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
def number_of(self, id):
|
||||||
|
"""The number an id was pushed under, or None.
|
||||||
|
|
||||||
|
Read off `.remote.json` rather than the issue file: a successful push
|
||||||
|
deletes the file, and the ledger is what is left behind."""
|
||||||
|
for key, got in _gitea.load_map(self.root).items():
|
||||||
|
if got == id:
|
||||||
|
return gmap.parse_remote_key(key)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# _gitea: the POST body, and the pre-check that reads links back
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AddDependencyTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_post_body_is_issue_meta(self):
|
||||||
|
"""POST /issues/{index}/dependencies with IssueMeta for the BLOCKER.
|
||||||
|
|
||||||
|
Confirmed against the instance's swagger.v1.json (Gitea 1.26.1):
|
||||||
|
"Make the issue in the url depend on the issue in the form." """
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_api(login, endpoint, method="GET", payload=None, **kw):
|
||||||
|
calls.append((method, endpoint, payload))
|
||||||
|
return {"number": 102}
|
||||||
|
|
||||||
|
with mock.patch.object(_gitea, "api", fake_api):
|
||||||
|
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101, out_root=None)
|
||||||
|
|
||||||
|
self.assertTrue(ok)
|
||||||
|
method, endpoint, payload = calls[0]
|
||||||
|
self.assertEqual(method, "POST")
|
||||||
|
self.assertEqual(endpoint, "%s/issues/102/dependencies" % BASE)
|
||||||
|
self.assertEqual(payload, {"index": 101, "owner": "claude-skills",
|
||||||
|
"repo": "tea"})
|
||||||
|
|
||||||
|
def test_blocker_may_live_in_another_repo(self):
|
||||||
|
"""IssueMeta carries owner/repo precisely so it can."""
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_api(login, endpoint, method="GET", payload=None, **kw):
|
||||||
|
seen.update(payload or {})
|
||||||
|
return {"number": 1}
|
||||||
|
|
||||||
|
with mock.patch.object(_gitea, "api", fake_api):
|
||||||
|
_gitea.add_dependency("l", BASE, 102, "other-org/infra", 7)
|
||||||
|
self.assertEqual(seen, {"index": 7, "owner": "other-org", "repo": "infra"})
|
||||||
|
|
||||||
|
def test_failure_is_reported_not_raised(self):
|
||||||
|
"""409 (link already there) and friends come back as False."""
|
||||||
|
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
|
||||||
|
self.assertFalse(_gitea.add_dependency("l", BASE, 102, REPO, 101))
|
||||||
|
|
||||||
|
def test_unparseable_repo_makes_no_request(self):
|
||||||
|
called = []
|
||||||
|
with mock.patch.object(_gitea, "api", lambda *a, **k: called.append(1)):
|
||||||
|
self.assertFalse(_gitea.add_dependency("l", BASE, 102, "tea", 101))
|
||||||
|
self.assertEqual(called, [])
|
||||||
|
|
||||||
|
def test_native_dep_pairs_reads_repo_and_number(self):
|
||||||
|
payload = [{"number": 101, "repository": {"full_name": REPO}},
|
||||||
|
{"number": 7, "repository": {"full_name": "other-org/infra"}}]
|
||||||
|
with mock.patch.object(_gitea, "api", lambda *a, **k: payload):
|
||||||
|
got = _gitea.native_dep_pairs("l", BASE, 102)
|
||||||
|
self.assertEqual(got, {(REPO, 101), ("other-org/infra", 7)})
|
||||||
|
|
||||||
|
def test_native_dep_pairs_empty_when_unsupported(self):
|
||||||
|
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
|
||||||
|
self.assertEqual(_gitea.native_dep_pairs("l", BASE, 102), set())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# push: the whole run
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PushCreatesLinksTest(PushTestCase):
|
||||||
|
|
||||||
|
def test_link_created_after_both_have_numbers(self):
|
||||||
|
"""One run, topological order, one native link — no second pass."""
|
||||||
|
self.two_issues()
|
||||||
|
out, _ = self.run_push()
|
||||||
|
|
||||||
|
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||||
|
self.assertLess(first, second, "blocker must be created first")
|
||||||
|
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
|
||||||
|
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
|
||||||
|
|
||||||
|
def test_link_direction_matches_what_pull_reads_back(self):
|
||||||
|
"""The link hangs off the BLOCKED issue, which is where native_deps
|
||||||
|
looks — push and `pull.py --deps` must agree or the round trip lies."""
|
||||||
|
self.two_issues()
|
||||||
|
self.run_push()
|
||||||
|
second = self.number_of("second-thing")
|
||||||
|
with mock.patch.object(_gitea, "api", self.fake.api):
|
||||||
|
self.assertEqual(_gitea.native_deps("l", BASE, second),
|
||||||
|
[self.number_of("first-thing")])
|
||||||
|
|
||||||
|
def test_issue_without_dependencies_makes_no_dependency_request(self):
|
||||||
|
"""Not even the idempotence GET — it is skipped when there is nothing
|
||||||
|
to link, so the common case costs no extra round trip."""
|
||||||
|
self.write_issue("lonely-thing", "Lonely thing")
|
||||||
|
self.run_push()
|
||||||
|
self.assertEqual([c for c in self.fake.calls if "dependencies" in c[1]], [])
|
||||||
|
|
||||||
|
|
||||||
|
class LocalOnlyDependencyTest(PushTestCase):
|
||||||
|
|
||||||
|
def test_local_dependency_is_warned_and_not_linked(self):
|
||||||
|
self.two_issues()
|
||||||
|
out, err = self.run_push("second-thing")
|
||||||
|
|
||||||
|
self.assertEqual(self.fake.dep_posts(), [])
|
||||||
|
self.assertIn("depends on local-only issue(s) first-thing", err)
|
||||||
|
self.assertNotIn("depends on ", out)
|
||||||
|
self.assertIsNone(self.number_of("first-thing"))
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotenceTest(PushTestCase):
|
||||||
|
|
||||||
|
def test_repeat_push_does_not_duplicate_the_link(self):
|
||||||
|
self.two_issues()
|
||||||
|
self.run_push()
|
||||||
|
self.assertEqual(len(self.fake.dep_posts()), 1)
|
||||||
|
|
||||||
|
self.repull("first-thing")
|
||||||
|
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
||||||
|
self.run_push("--update")
|
||||||
|
self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed")
|
||||||
|
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
||||||
|
{(REPO, self.number_of("first-thing"))})
|
||||||
|
|
||||||
|
def test_a_failing_link_warns_and_the_run_finishes(self):
|
||||||
|
"""A 409 or any other refusal must not abort a push that has already
|
||||||
|
created issues."""
|
||||||
|
self.two_issues()
|
||||||
|
self.fake.fail_dependency_post = True
|
||||||
|
out, err = self.run_push()
|
||||||
|
|
||||||
|
self.assertIn("could not link", err)
|
||||||
|
self.assertIn("index:", out) # the run completed
|
||||||
|
self.assertIsNotNone(self.number_of("second-thing"))
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCarriesNewLinksTest(PushTestCase):
|
||||||
|
|
||||||
|
def test_dependency_added_after_the_first_push_is_linked_by_update(self):
|
||||||
|
self.write_issue("first-thing", "First thing")
|
||||||
|
self.write_issue("second-thing", "Second thing")
|
||||||
|
self.run_push()
|
||||||
|
self.assertEqual(self.fake.dep_posts(), [])
|
||||||
|
|
||||||
|
# The issue comes back from Gitea, and the dependency is added to the
|
||||||
|
# copy that came back — there is no other copy to add it to.
|
||||||
|
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
||||||
|
|
||||||
|
self.run_push("--update", "second-thing")
|
||||||
|
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
||||||
|
{(REPO, self.number_of("first-thing"))})
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunTest(PushTestCase):
|
||||||
|
|
||||||
|
def test_dry_run_names_the_links_and_writes_nothing(self):
|
||||||
|
self.two_issues()
|
||||||
|
out, _ = self.run_push("--dry-run")
|
||||||
|
|
||||||
|
self.assertEqual(self.fake.calls, [], "--dry-run made a request")
|
||||||
|
self.assertIn("link -> #? (first-thing, created by this run)", out)
|
||||||
|
self.assertIn("1 dependency link(s) would be created", out)
|
||||||
|
|
||||||
|
def test_dry_run_shows_a_known_number_when_the_blocker_is_pushed(self):
|
||||||
|
self.write_issue("first-thing", "First thing",
|
||||||
|
extra={"gitea": "%s#101" % REPO})
|
||||||
|
self.write_issue("second-thing", "Second thing", body=BODY,
|
||||||
|
depends=["first-thing"])
|
||||||
|
out, _ = self.run_push("--dry-run")
|
||||||
|
|
||||||
|
self.assertIn("link -> %s#101 (first-thing)" % REPO, out)
|
||||||
|
self.assertEqual(self.fake.writes, [])
|
||||||
|
|
||||||
|
def test_dry_run_says_a_local_dependency_gets_no_link(self):
|
||||||
|
self.two_issues()
|
||||||
|
out, _ = self.run_push("--dry-run", "second-thing")
|
||||||
|
self.assertIn("no link: first-thing is local-only", out)
|
||||||
|
self.assertIn("0 dependency link(s) would be created", out)
|
||||||
|
|
||||||
|
|
||||||
|
class BodyIsVerbatimTest(PushTestCase):
|
||||||
|
"""The prose is untouched. The id marker is the one thing push adds, and it
|
||||||
|
comes straight back off — `strip_id_marker` is the inverse."""
|
||||||
|
|
||||||
|
def test_depends_on_prose_is_not_rewritten_to_numbers(self):
|
||||||
|
"""map.py deliberately never edits the prose. Linking must not start."""
|
||||||
|
self.two_issues()
|
||||||
|
before = issue.load(self.root, "second-thing").body
|
||||||
|
self.run_push()
|
||||||
|
|
||||||
|
created = [c for c in self.fake.calls
|
||||||
|
if c[0] == "POST" and c[1] == "%s/issues" % BASE]
|
||||||
|
sent = [c[2]["body"] for c in created]
|
||||||
|
second_body = [b for b in sent if "Depends on" in b][0]
|
||||||
|
|
||||||
|
self.assertIn("- first-thing — ставит фундамент", second_body)
|
||||||
|
self.assertNotIn("#101", second_body)
|
||||||
|
self.assertEqual(gmap.strip_id_marker(second_body), before)
|
||||||
|
|
||||||
|
def test_body_survives_a_second_push_unchanged(self):
|
||||||
|
self.two_issues()
|
||||||
|
before = issue.load(self.root, "second-thing").body
|
||||||
|
self.run_push()
|
||||||
|
|
||||||
|
self.repull("first-thing")
|
||||||
|
self.repull("second-thing", body=before, depends=["first-thing"])
|
||||||
|
self.assertEqual(issue.load(self.root, "second-thing").body, before)
|
||||||
|
|
||||||
|
self.run_push("--update")
|
||||||
|
patched = [c for c in self.fake.calls if c[0] == "PATCH"]
|
||||||
|
self.assertIn(before, [gmap.strip_id_marker(c[2]["body"]) for c in patched])
|
||||||
|
|
||||||
|
|
||||||
|
class DepStateTest(PushTestCase):
|
||||||
|
"""The classifier both the dry run and the real run read from."""
|
||||||
|
|
||||||
|
def test_classifies_linked_in_run_and_local(self):
|
||||||
|
issues = {
|
||||||
|
"pushed": issue.Issue(id="pushed", extra={"gitea": "%s#101" % REPO}),
|
||||||
|
"coming": issue.Issue(id="coming"),
|
||||||
|
"local": issue.Issue(id="local"),
|
||||||
|
}
|
||||||
|
iss = issue.Issue(id="dependent",
|
||||||
|
depends=["pushed", "coming", "local", "ghost"])
|
||||||
|
got = push.dep_state(iss, issues, {"coming", "dependent"})
|
||||||
|
|
||||||
|
self.assertEqual(got, [("pushed", "%s#101" % REPO, False),
|
||||||
|
("coming", None, True),
|
||||||
|
("local", None, False)])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Where the issue store is, and that the answer does not depend on cwd.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
Stdlib unittest, no third-party anything — the same rule the scripts under test
|
||||||
|
live by. `skills/*/scripts/` are not packages, so the domain module is imported
|
||||||
|
by path.
|
||||||
|
|
||||||
|
Most of these tests do not touch this repository at all. They build a throwaway
|
||||||
|
repo in a temp directory — a `.git` marker, a copy of both script layers, a
|
||||||
|
store with two issues — and run the real scripts inside it as subprocesses with
|
||||||
|
different working directories. That is the only honest way to test a cwd bug:
|
||||||
|
importing the module would resolve the store once, against the wrong tree.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||||
|
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||||
|
|
||||||
|
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||||
|
import issue # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
ALPHA = """\
|
||||||
|
---
|
||||||
|
id: alpha-issue
|
||||||
|
state: open
|
||||||
|
labels: [type/task]
|
||||||
|
assignees: []
|
||||||
|
milestone: none
|
||||||
|
depends: []
|
||||||
|
origin: local
|
||||||
|
---
|
||||||
|
# Alpha issue
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Первый issue фикстуры.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
none
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Нужен, чтобы в store что-то лежало.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] проверяемое условие
|
||||||
|
"""
|
||||||
|
|
||||||
|
BETA = """\
|
||||||
|
---
|
||||||
|
id: beta-issue
|
||||||
|
state: open
|
||||||
|
labels: [type/task]
|
||||||
|
assignees: []
|
||||||
|
milestone: none
|
||||||
|
depends: [alpha-issue]
|
||||||
|
origin: local
|
||||||
|
---
|
||||||
|
# Beta issue
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Второй issue фикстуры, зависит от первого.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
none
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
- alpha-issue
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Нужен, чтобы у графа было ребро.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] проверяемое условие
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def run(script, *args, **kw):
|
||||||
|
"""Run one of the plugin's scripts and return (rc, stdout, stderr)."""
|
||||||
|
cwd = kw.pop("cwd")
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
||||||
|
p = subprocess.run([sys.executable, script] + list(args), cwd=cwd, env=env,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return p.returncode, p.stdout, p.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepo(object):
|
||||||
|
"""A self-contained repository in a temp directory.
|
||||||
|
|
||||||
|
Both script layers are copied in, so `__file__`-anchored resolution lands
|
||||||
|
inside the fixture and never on the developer's real store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, with_store=True, issues=(ALPHA, BETA)):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
# realpath: on macOS $TMPDIR is a symlink, and a child process reporting
|
||||||
|
# its own cwd would otherwise disagree with the path we handed it.
|
||||||
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(self.root, ".git")) # the repo marker
|
||||||
|
skip = shutil.ignore_patterns("__pycache__")
|
||||||
|
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
||||||
|
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
|
||||||
|
os.makedirs(self.path("sub", "deeper"))
|
||||||
|
|
||||||
|
if with_store:
|
||||||
|
os.makedirs(self.store)
|
||||||
|
for text in issues:
|
||||||
|
id = text.split("id: ", 1)[1].split("\n", 1)[0]
|
||||||
|
with open(os.path.join(self.store, "%s.md" % id), "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def path(self, *parts):
|
||||||
|
return os.path.join(self.root, *parts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def store(self):
|
||||||
|
return self.path("tmp", "issues")
|
||||||
|
|
||||||
|
def script(self, layer, name):
|
||||||
|
return self.path("skills", layer, "scripts", name)
|
||||||
|
|
||||||
|
def everywhere(self):
|
||||||
|
"""Working directories that must all produce the same answer: the repo
|
||||||
|
root, a plain subdirectory, a deeper one, the script directory itself,
|
||||||
|
and — the case from the bug report — inside the store."""
|
||||||
|
return [self.root, self.path("sub"), self.path("sub", "deeper"),
|
||||||
|
self.path("skills", "issue", "scripts"), self.store]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# resolution, in isolation
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestResolution(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.repo = FakeRepo()
|
||||||
|
self.addCleanup(self.repo.cleanup)
|
||||||
|
|
||||||
|
def test_repo_root_found_from_any_depth(self):
|
||||||
|
for start in self.repo.everywhere():
|
||||||
|
self.assertEqual(issue.repo_root(start), self.repo.root, start)
|
||||||
|
|
||||||
|
def test_agents_md_works_as_a_marker(self):
|
||||||
|
"""A checkout without .git — the plugin copied out of git — still
|
||||||
|
resolves, because AGENTS.md marks the root too."""
|
||||||
|
shutil.rmtree(self.repo.path(".git"))
|
||||||
|
open(self.repo.path("AGENTS.md"), "w").close()
|
||||||
|
self.assertEqual(issue.repo_root(self.repo.path("sub", "deeper")),
|
||||||
|
self.repo.root)
|
||||||
|
|
||||||
|
def test_nearest_marker_wins(self):
|
||||||
|
"""A repo inside a repo (a worktree, a vendored copy) resolves to the
|
||||||
|
inner one, not the outer."""
|
||||||
|
inner = self.repo.path("sub", "inner")
|
||||||
|
os.makedirs(os.path.join(inner, ".git"))
|
||||||
|
self.assertEqual(issue.repo_root(inner), inner)
|
||||||
|
self.assertEqual(issue.repo_root(self.repo.root), self.repo.root)
|
||||||
|
|
||||||
|
def test_store_root_is_repo_root_plus_tmp_issues(self):
|
||||||
|
self.assertEqual(issue.store_root(self.repo.path("sub", "deeper")),
|
||||||
|
self.repo.store)
|
||||||
|
|
||||||
|
def test_default_root_is_absolute(self):
|
||||||
|
"""The whole point: a default that cannot mean two directories."""
|
||||||
|
self.assertTrue(os.path.isabs(issue.ISSUE_ROOT), issue.ISSUE_ROOT)
|
||||||
|
self.assertEqual(issue.ISSUE_ROOT,
|
||||||
|
os.path.join(REPO, "tmp", "issues"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the acceptance criterion: same answer from any subdirectory
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSameFromAnywhere(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.repo = FakeRepo()
|
||||||
|
self.addCleanup(self.repo.cleanup)
|
||||||
|
|
||||||
|
def assertSameEverywhere(self, layer, name, *args):
|
||||||
|
"""Run the script from the repo root and from every other directory;
|
||||||
|
every result must be byte-identical to the one from the root."""
|
||||||
|
dirs = self.repo.everywhere()
|
||||||
|
base = run(self.repo.script(layer, name), *args, cwd=dirs[0])
|
||||||
|
self.assertEqual(base[0], 0, "%s failed at the repo root:\n%s" % (name, base[2]))
|
||||||
|
for d in dirs[1:]:
|
||||||
|
self.assertEqual(run(self.repo.script(layer, name), *args, cwd=d), base,
|
||||||
|
"%s disagrees when run from %s" % (name, d))
|
||||||
|
return base
|
||||||
|
|
||||||
|
def test_issue_check(self):
|
||||||
|
rc, out, _ = self.assertSameEverywhere("issue", "issue_check.py")
|
||||||
|
self.assertIn("ok alpha-issue", out)
|
||||||
|
self.assertIn("2 issue(s) checked, 0 with errors", out)
|
||||||
|
|
||||||
|
def test_issue_tree(self):
|
||||||
|
_, out, _ = self.assertSameEverywhere("issue", "issue_tree.py")
|
||||||
|
self.assertIn("beta-issue", out)
|
||||||
|
self.assertIn("alpha-issue", out)
|
||||||
|
|
||||||
|
def test_issue_index(self):
|
||||||
|
_, out, _ = self.assertSameEverywhere("issue", "issue_index.py")
|
||||||
|
self.assertIn("2 issue(s)", out)
|
||||||
|
self.assertIn(os.path.join(self.repo.store, "INDEX.md"), out)
|
||||||
|
|
||||||
|
def test_no_second_store_is_ever_created(self):
|
||||||
|
"""The bug's worst symptom: `issue_index.py` run from inside the store
|
||||||
|
used to leave tmp/issues/tmp/issues/ behind, silently."""
|
||||||
|
for d in self.repo.everywhere():
|
||||||
|
for name in ("issue_index.py", "issue_check.py", "issue_tree.py"):
|
||||||
|
run(self.repo.script("issue", name), cwd=d)
|
||||||
|
|
||||||
|
found = []
|
||||||
|
for dirpath, dirnames, filenames in os.walk(self.repo.root):
|
||||||
|
if "__pycache__" in dirnames:
|
||||||
|
dirnames.remove("__pycache__")
|
||||||
|
if "INDEX.md" in filenames:
|
||||||
|
found.append(dirpath)
|
||||||
|
self.assertEqual(found, [self.repo.store],
|
||||||
|
"a second store appeared: %s" % found)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# missing is not empty
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMissingVersusEmpty(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_missing_store_says_missing(self):
|
||||||
|
repo = FakeRepo(with_store=False)
|
||||||
|
self.addCleanup(repo.cleanup)
|
||||||
|
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
||||||
|
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
|
||||||
|
msg = out + err
|
||||||
|
self.assertNotEqual(rc, 0, "%s should fail on a missing store" % name)
|
||||||
|
self.assertIn("does not exist", msg, name)
|
||||||
|
self.assertNotIn("is empty", msg, name)
|
||||||
|
|
||||||
|
def test_empty_store_says_empty(self):
|
||||||
|
repo = FakeRepo(issues=())
|
||||||
|
self.addCleanup(repo.cleanup)
|
||||||
|
for name in ("issue_check.py", "issue_tree.py"):
|
||||||
|
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
|
||||||
|
msg = out + err
|
||||||
|
self.assertNotEqual(rc, 0, name)
|
||||||
|
self.assertIn("is empty", msg, name)
|
||||||
|
self.assertNotIn("does not exist", msg, name)
|
||||||
|
|
||||||
|
def test_index_of_an_empty_store_is_legitimate(self):
|
||||||
|
"""An existing store with nothing in it gets an index saying so. Only a
|
||||||
|
missing directory is an error."""
|
||||||
|
repo = FakeRepo(issues=())
|
||||||
|
self.addCleanup(repo.cleanup)
|
||||||
|
rc, out, err = run(repo.script("issue", "issue_index.py"), cwd=repo.root)
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("0 issue(s)", out)
|
||||||
|
with open(os.path.join(repo.store, "INDEX.md")) as f:
|
||||||
|
self.assertIn("_empty_", f.read())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# nothing conjures a store
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNoSilentCreation(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.repo = FakeRepo(with_store=False)
|
||||||
|
self.addCleanup(self.repo.cleanup)
|
||||||
|
|
||||||
|
def test_readers_and_the_indexer_create_nothing(self):
|
||||||
|
for d in (self.repo.root, self.repo.path("sub")):
|
||||||
|
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
||||||
|
run(self.repo.script("issue", name), cwd=d)
|
||||||
|
self.assertFalse(os.path.exists(self.repo.path("tmp")),
|
||||||
|
"the store was created by a read")
|
||||||
|
self.assertFalse(os.path.exists(self.repo.path("sub", "tmp")),
|
||||||
|
"a store was created relative to cwd")
|
||||||
|
|
||||||
|
def test_explicit_out_pointing_nowhere_is_an_error_not_a_mkdir(self):
|
||||||
|
target = self.repo.path("sub", "nowhere")
|
||||||
|
rc, out, err = run(self.repo.script("issue", "issue_index.py"),
|
||||||
|
"--out", target, cwd=self.repo.root)
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn("does not exist", out + err)
|
||||||
|
self.assertFalse(os.path.exists(target))
|
||||||
|
|
||||||
|
def test_issue_new_creates_the_store_and_says_so(self):
|
||||||
|
"""Creating the first issue in a fresh checkout must still work — but
|
||||||
|
out loud, and at the repo root, not below whatever cwd happens to be."""
|
||||||
|
rc, out, err = run(self.repo.script("issue", "issue_new.py"),
|
||||||
|
"--type", "task", "--title", "Bootstrap the store",
|
||||||
|
cwd=self.repo.path("sub", "deeper"))
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("created store", err)
|
||||||
|
self.assertIn(self.repo.store, err)
|
||||||
|
self.assertTrue(os.path.isfile(
|
||||||
|
os.path.join(self.repo.store, "bootstrap-the-store.md")))
|
||||||
|
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")),
|
||||||
|
"a store was created relative to cwd")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# an explicit --out is the operator's, not ours to rewrite
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExplicitOutWins(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.repo = FakeRepo()
|
||||||
|
self.addCleanup(self.repo.cleanup)
|
||||||
|
|
||||||
|
def test_absolute_out_is_honored(self):
|
||||||
|
other = self.repo.path("sub", "other-store")
|
||||||
|
os.makedirs(other)
|
||||||
|
shutil.copy(os.path.join(self.repo.store, "alpha-issue.md"), other)
|
||||||
|
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||||
|
"--out", other, cwd=self.repo.root)
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("1 issue(s) checked", out)
|
||||||
|
|
||||||
|
def test_relative_out_stays_relative_to_cwd(self):
|
||||||
|
"""`--out tmp/issues` typed from a subdirectory means that
|
||||||
|
subdirectory's tmp/issues — which is not there. Auto-resolution must
|
||||||
|
not step in and "fix" what the operator typed."""
|
||||||
|
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||||
|
"--out", os.path.join("tmp", "issues"),
|
||||||
|
cwd=self.repo.path("sub"))
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn("does not exist", out + err)
|
||||||
|
|
||||||
|
# the same relative path from the root does resolve, by cwd alone
|
||||||
|
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||||
|
"--out", os.path.join("tmp", "issues"),
|
||||||
|
cwd=self.repo.root)
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("2 issue(s) checked", out)
|
||||||
|
|
||||||
|
def test_relative_out_can_climb(self):
|
||||||
|
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||||
|
"--out", os.path.join("..", "tmp", "issues"),
|
||||||
|
cwd=self.repo.path("sub"))
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("2 issue(s) checked", out)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# both layers, one root
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSyncLayerAgrees(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.repo = FakeRepo()
|
||||||
|
self.addCleanup(self.repo.cleanup)
|
||||||
|
|
||||||
|
def _probe(self, layer, cwd):
|
||||||
|
"""Ask one layer, from `cwd`, which module defines the store and where
|
||||||
|
it lands. The sync scripts put the issue scripts on sys.path themselves
|
||||||
|
— `import map` is how they do it — so each layer is asked its own way.
|
||||||
|
"""
|
||||||
|
scripts = self.repo.path("skills", layer, "scripts")
|
||||||
|
entry = "import map, issue" if layer == "sync" else "import issue"
|
||||||
|
code = ("import sys; sys.path.insert(0, %r)\n%s\n"
|
||||||
|
"print(issue.__file__)\nprint(issue.ISSUE_ROOT)\n") % (scripts, entry)
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("PYTHONPATH", None)
|
||||||
|
p = subprocess.run([sys.executable, "-c", code], cwd=cwd, env=env,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
return p.stdout.strip().splitlines()
|
||||||
|
|
||||||
|
def test_both_layers_resolve_the_same_store_from_anywhere(self):
|
||||||
|
for d in self.repo.everywhere():
|
||||||
|
mod_i, root_i = self._probe("issue", d)
|
||||||
|
mod_s, root_s = self._probe("sync", d)
|
||||||
|
# sync does not redefine the store; it imports the domain module
|
||||||
|
self.assertEqual(os.path.realpath(mod_i), os.path.realpath(mod_s), d)
|
||||||
|
self.assertEqual(root_i, self.repo.store, d)
|
||||||
|
self.assertEqual(root_s, self.repo.store, d)
|
||||||
|
|
||||||
|
def test_every_out_flag_defers_to_the_domain_layer(self):
|
||||||
|
"""Both layers agree by construction, not by coincidence: no script
|
||||||
|
spells the default out for itself."""
|
||||||
|
for layer, names in (("issue", ("issue_new.py", "issue_check.py",
|
||||||
|
"issue_tree.py", "issue_index.py")),
|
||||||
|
("sync", ("pull.py", "push.py", "remote.py",
|
||||||
|
"comment.py"))):
|
||||||
|
for name in names:
|
||||||
|
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
|
||||||
|
src = f.read()
|
||||||
|
self.assertIn('"--out", default=issue.ISSUE_ROOT', src,
|
||||||
|
"%s/%s does not take its --out default from the "
|
||||||
|
"domain layer" % (layer, name))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the layering rule, mechanically
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLayering(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_domain_layer_is_stdlib_only(self):
|
||||||
|
"""skills/issue must keep working with skills/sync deleted — so no
|
||||||
|
transport, and above all no subprocess, in the domain layer."""
|
||||||
|
imported = set()
|
||||||
|
for name in sorted(os.listdir(ISSUE_SCRIPTS)):
|
||||||
|
if not name.endswith(".py"):
|
||||||
|
continue
|
||||||
|
with open(os.path.join(ISSUE_SCRIPTS, name)) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith(("import ", "from ")):
|
||||||
|
imported.add(line.split()[1].split(".")[0])
|
||||||
|
local = {"issue", "issue_ac", "issue_index"}
|
||||||
|
foreign = imported - local - sys.stdlib_module_names
|
||||||
|
self.assertEqual(foreign, set(),
|
||||||
|
"non-stdlib import in the domain layer: %s"
|
||||||
|
% ", ".join(sorted(foreign)))
|
||||||
|
self.assertNotIn("subprocess", imported)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user