Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a8bd1c299 | |||
| 81119a3bd9 | |||
| f97ac952f7 | |||
| 493a787940 | |||
| 7ab967bfaa | |||
| edb2f5a627 | |||
| 62027db76c | |||
| 9479babfe9 | |||
| e330a11e8f | |||
| bf0936526d | |||
| f7cffd7c48 | |||
| e6b4cf773c | |||
| 17567cd6a2 | |||
| f5977fa4fc | |||
| 74a0e3b173 | |||
| bb964d5a55 | |||
| 40016e06f2 | |||
| 2a8da81359 | |||
| c18b16a14b | |||
| cfbd6e5ddb | |||
| 1d7abc11ae | |||
| a2e9a88186 | |||
| 627df76812 | |||
| 2f82b501bd | |||
| 9679e2c000 | |||
| 596cf853e8 | |||
| bea3735e47 | |||
| 2ac301550e | |||
| e629d14585 |
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "tea",
|
"name": "tea",
|
||||||
"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.",
|
"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.",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "naudachu"
|
"name": "naudachu"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": ["gitea", "cli", "git", "issues", "wiki", "login-guard"]
|
"keywords": ["gitea", "cli", "git", "issues", "login-guard"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,42 +15,44 @@
|
|||||||
|
|
||||||
## Layers
|
## Layers
|
||||||
|
|
||||||
The hard rule of this repo. Two domains, two bridges, one transport, and
|
The hard rule of this repo. One domain, one bridge, one transport, and
|
||||||
knowledge flows one way only:
|
knowledge flows one way only:
|
||||||
|
|
||||||
```
|
```
|
||||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
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
|
▲ offline — no tracker, no network, stdlib imports only
|
||||||
│ imports
|
│ imports
|
||||||
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
||||||
_gitea.py login pin, tea api, pagination, filters
|
_gitea.py tea api, pagination, filters, payloads
|
||||||
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
|
│ imports
|
||||||
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
|
skills/auth IDENTITY pin the login the whole tracker side runs under
|
||||||
|
▲ pin.py where the pin is and how it is found —
|
||||||
|
│ imports imported by _gitea.py AND by hooks/tea-guard.sh
|
||||||
|
hooks/tea-guard so `tea` and the scripts cannot disagree
|
||||||
|
|
||||||
|
skills/use REFERENCE tea CLI docs for everything that is not an issue
|
||||||
▲
|
▲
|
||||||
│ calls
|
│ calls
|
||||||
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
||||||
```
|
```
|
||||||
|
|
||||||
A domain never imports its bridge, and the two domains do not import each
|
The domain never imports its bridge: delete `skills/sync` and issues still
|
||||||
other: delete `skills/sync` and issues still work, delete `skills/wiki` and page
|
work. The check is mechanical — every import under the domain's `scripts/` is
|
||||||
trees still work, delete either domain and the other is untouched. The check is
|
stdlib, and `subprocess` is not among them:
|
||||||
mechanical — every import under a domain's `scripts/` is stdlib, and
|
|
||||||
`subprocess` is not among them:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
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`,
|
If a tracker concept (issue number, login, HTTP call, label color) shows up in
|
||||||
`content_base64`) shows up in a domain layer, it is in the wrong place.
|
the domain layer, it is in the wrong place.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
||||||
|
- `scripts/pin.py` — the one written copy of the pin's location and search
|
||||||
|
order (see "The login pin" below); stdlib, no subprocess, no network
|
||||||
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
||||||
- `references/format.md` — canonical issue format; single source of truth
|
- `references/format.md` — canonical issue format; single source of truth
|
||||||
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
||||||
@@ -60,26 +62,19 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
|||||||
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
|
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
|
||||||
substring, changing exactly one character of the file
|
substring, changing exactly one character of the file
|
||||||
- `scripts/issue_tree.py` — draw the dependency graph
|
- `scripts/issue_tree.py` — draw the dependency graph
|
||||||
|
- `scripts/issue_evict.py` — remove closed issues from the store; never an
|
||||||
|
`origin: local` one
|
||||||
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
||||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
- `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/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
||||||
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
|
- `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids,
|
||||||
label ids, the remote-id map
|
the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py`
|
||||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
||||||
- `skills/page` — a discussion's artifacts as a page tree (`/tea:page`),
|
- `scripts/close.py` — the state field, both ways; explicit ids only
|
||||||
entirely offline
|
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
|
||||||
- `references/pages.md` — canonical page-tree format; single source of truth
|
the domain's `issue_evict.run`
|
||||||
- `scripts/page.py` — domain module: title ↔ path, ordering, the manifest,
|
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
|
||||||
importing a directory of markdown, the index
|
repository; reads the domain taxonomy, never the store
|
||||||
- `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
|
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
||||||
(`/tea:use`); `references/tea/` holds the command docs
|
(`/tea:use`); `references/tea/` holds the command docs
|
||||||
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
||||||
@@ -89,10 +84,40 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
|||||||
Delegating a single call costs more than running it inline — the win is the
|
Delegating a single call costs more than running it inline — the win is the
|
||||||
loop, the retry, and the error triage.
|
loop, the retry, and the error triage.
|
||||||
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
||||||
that don't use the pinned login; `agents-sync` keeps every directory canonical
|
that don't use the pinned login (resolving it through `auth/pin.py`);
|
||||||
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
`agents-sync` keeps every directory canonical (`AGENTS.md` real file,
|
||||||
|
`CLAUDE.md` symlink to it)
|
||||||
- `tests/` — stdlib `unittest`, no third-party anything
|
- `tests/` — stdlib `unittest`, no third-party anything
|
||||||
|
|
||||||
|
## The login pin
|
||||||
|
|
||||||
|
`<project root>/.claude/settings.local.json` → `env.GITEA_LOGIN`, written by
|
||||||
|
`/tea:auth` and read at call time. **The search order is written once, in
|
||||||
|
`skills/auth/scripts/pin.py`**, and both callers import it: the transport
|
||||||
|
(`_gitea.require_login`) and the `tea-guard` hook. Neither spells the path or
|
||||||
|
the walk itself, and a test asserts they don't.
|
||||||
|
|
||||||
|
Start directories, first hit wins: `$CLAUDE_PROJECT_DIR`, then a hint the
|
||||||
|
caller supplies (the hook passes the Bash payload's `cwd`; a script passes
|
||||||
|
nothing), then the current directory. Each one is searched up its parent chain,
|
||||||
|
and then — only if that found nothing — up the parent chain of the **main
|
||||||
|
working tree of any linked worktree** met on the way, reached by reading
|
||||||
|
`gitdir:` out of a `.git` *file* and following `commondir`.
|
||||||
|
|
||||||
|
**The pin is not resolved from `__file__`, and that asymmetry with
|
||||||
|
`issue.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.**
|
||||||
|
Where an installation keeps its files is a fact about the installation; whose
|
||||||
|
login a project runs under is a fact about the project. A plugin installed
|
||||||
|
outside any repository and pointed at somebody else's tree must not answer the
|
||||||
|
second question from its own directory. So the search runs from the working
|
||||||
|
directory upward — and reaches a worktree's main checkout by asking git.
|
||||||
|
|
||||||
|
Two failures this replaces, both worth remembering: a git worktree is a
|
||||||
|
*sibling* of the main checkout, so the untracked pin is not on its parent chain
|
||||||
|
and the whole sync layer died there while `tea` in the same directory worked;
|
||||||
|
and the cure it invited — `/tea:auth` inside the worktree — writes a second
|
||||||
|
settings file into a directory that is deleted with the worktree.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -104,18 +129,28 @@ 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
|
packages, so a test that needs the domain module imports it with
|
||||||
`sys.path.insert`.
|
`sys.path.insert`.
|
||||||
|
|
||||||
**A test never touches `tmp/issues/` or `tmp/wiki/`.** Anything that needs a
|
**A test never touches `tmp/issues/` or `tmp/payload/`.** Anything that needs a
|
||||||
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
||||||
`.git` marker, a copy of the script layers, fixture issues or artifacts — and
|
`.git` marker, a copy of the script layers, fixture issues — and runs the real
|
||||||
runs the real scripts inside it as subprocesses. That is the only way to test
|
scripts inside it as subprocesses. That is the only way to test behavior that depends on where a
|
||||||
behavior that depends on where a script is run from, and it keeps the
|
script is run from, and it keeps the developer's own store out of the blast
|
||||||
developer's own store out of the blast radius.
|
radius.
|
||||||
|
|
||||||
|
`tmp/payload/` is in that list because `_gitea.PAYLOAD_ROOT` is resolved once,
|
||||||
|
from the module's own location: a test that stubs the transport *below* `api()`
|
||||||
|
— at `subprocess`, to exercise a non-2xx — reaches the real write. Such a test
|
||||||
|
patches `PAYLOAD_ROOT` to its own temp directory too.
|
||||||
|
|
||||||
## Local issue store
|
## Local issue store
|
||||||
|
|
||||||
`tmp/issues/` (gitignored) is **the store, not a cache of Gitea**. One flat
|
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
|
||||||
markdown file per issue, named by its slug, with one metadata field per line so
|
store.** An `origin: local` issue lives here and nowhere else — this file *is*
|
||||||
plain grep works without a parser.
|
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
|
- **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
|
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
|
||||||
@@ -127,35 +162,67 @@ plain grep works without a parser.
|
|||||||
and they say so on stderr.
|
and they say so on stderr.
|
||||||
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
|
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
|
||||||
Numbers live in the `gitea:` field.
|
Numbers live in the `gitea:` field.
|
||||||
- `origin: local` is a durable state. An issue that never leaves this machine is
|
- `origin: local` is a complete state, not a draft: an issue that never leaves
|
||||||
complete and valid, not a draft.
|
this machine is valid and finished. It is not a *durable* state, though —
|
||||||
- Pushing is additive: the file is never deleted, it gains `gitea:` / `url:` /
|
pushing ends it, and the local file goes with it.
|
||||||
`synced:`.
|
- **A successful push deletes the local file** (`<id>.md` and
|
||||||
- Pulling overwrites the body — a fetch, not a merge.
|
`<id>.comments.md`), and prints the number and URL the issue now lives at.
|
||||||
- No drift tracking. `synced:` tells you how old your copy is; re-pull when it
|
`--update` too: one rule, no exception. What is in the store is what has not
|
||||||
matters.
|
left. Get it back with `pull.py <n>` — which brings its blockers back with it:
|
||||||
|
a pull returns the unit of work, not one row of it. `--no-deps` narrows it to
|
||||||
|
the one issue, and the cost of the default is in `pull.py`'s docstring.
|
||||||
|
- 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. **Eviction does not prune it either**, for the same reason a
|
||||||
|
push does not: an evicted issue is in exactly the state a pushed one is.
|
||||||
|
- **A closed issue is evicted, not archived.** `issue_evict.py` removes
|
||||||
|
`<id>.md` and every sidecar under that slug for anything that is `state:
|
||||||
|
closed` **and** carries an `origin:` naming a tracker, then rebuilds
|
||||||
|
`INDEX.md`. `--dry-run` prints and writes nothing. **`origin: local` is never
|
||||||
|
evicted, in any state, not even when named on the command line** — that file
|
||||||
|
*is* the issue and nothing can fetch it back.
|
||||||
|
- **Eviction lives in the domain** (`skills/issue/scripts/issue_evict.py`),
|
||||||
|
because its two inputs — `state:` and `origin:` — are domain fields and the
|
||||||
|
answer is already on disk. No network, no login, no `tea`.
|
||||||
|
`skills/sync/scripts/evict.py` is the bridge form: it refreshes `state:` from
|
||||||
|
the tracker first (a local `state:` is only as fresh as the last pull) and then
|
||||||
|
calls `issue_evict.run`. One implementation of "what may be evicted", in the
|
||||||
|
layer that owns the fields it reads. Same gate as push, one step earlier: a
|
||||||
|
failed or unconfirmed tracker answer evicts nothing at all.
|
||||||
|
- **Pull by number fetches an issue in any state — a number is a number.** An
|
||||||
|
address is not a query: `pull.py 42` puts a closed issue on disk exactly as it
|
||||||
|
always has, and so does `#42`, `owner/repo#42`, or its URL. Only filter mode
|
||||||
|
(`--milestone`, `--label`, `-q`) leaves closed issues out. Eviction does not
|
||||||
|
revoke this: a closed issue pulled after a cleanup lands on disk again, and
|
||||||
|
that is the tracker answering what it was asked, not a regression. Evict it
|
||||||
|
again when you are done with it.
|
||||||
|
- 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
|
## Request payloads
|
||||||
|
|
||||||
`tmp/wiki/<space>/` (gitignored) holds page trees — a discussion's artifacts,
|
`tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
|
||||||
organized. Same stance as the issue store, resolved the same way from
|
one file per named request, kept after the call for a retry or a post-mortem.
|
||||||
`page.py`'s own location, with the same `--out` rule.
|
It is **not a store and holds nobody's only copy** — deleting it costs nothing.
|
||||||
|
|
||||||
- Identity is the **title**, and `/` inside it is the only hierarchy there is.
|
- One directory for every caller, resolved from `_gitea.py`'s own location, so
|
||||||
The Gitea wiki is flat: it escapes a title into one filename by rules of its
|
which command wrote a body does not change where it landed. `_gitea.api` takes no directory argument; that it once did
|
||||||
own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`).
|
is exactly how a label bootstrap came to create `tmp/issues/`.
|
||||||
- **`sub_url` is Gitea's address for a page and is never constructed.** It is
|
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
|
||||||
read back from the API and stored in `.pages.json`. One built by hand that is
|
or a run with nothing to send leaves no directory behind.
|
||||||
almost right creates a second page instead of editing the first.
|
- **A scratchpad may never sit inside a store.** Store contents are the thing
|
||||||
- **Never commit a subdirectory into a wiki's git repository.** Gitea does not
|
being tracked; request bodies are debris of the transport. When the two share
|
||||||
see it — the page exists on disk and nowhere in the API or the UI. Do not
|
a path, an operation that touches no issue at all still materializes the issue
|
||||||
clone the wiki repo to work in; use the scripts.
|
store, and the operator's `ls tmp/issues` starts lying about what exists.
|
||||||
- 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 durable state, exactly
|
|
||||||
as `origin: local` is for an issue.
|
|
||||||
- Change detection is one hash (`pushed`). Pulling overwrites; pushing is
|
|
||||||
additive and never deletes.
|
|
||||||
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
|
|
||||||
`_gitea.py`.
|
|
||||||
|
|||||||
@@ -8,30 +8,30 @@ A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforce
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
|
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
|
||||||
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
|
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
|
||||||
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment |
|
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
|
||||||
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
|
| `/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-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
|
## The layering
|
||||||
|
|
||||||
An issue is a unit of work first and a Gitea row second. Those are two layers,
|
An issue is a unit of work first and a Gitea row second. That is two layers,
|
||||||
and knowledge flows one way:
|
and knowledge flows one way:
|
||||||
|
|
||||||
```
|
```
|
||||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||||
▲ offline — no tracker, no network, stdlib only
|
▲ offline — no tracker, no network, stdlib only
|
||||||
│ imports
|
│ imports
|
||||||
skills/sync BRIDGE md <-> Gitea JSON, then over the wire
|
skills/sync BRIDGE md <-> Gitea issue JSON, then over the wire
|
||||||
▲
|
▲
|
||||||
│ calls
|
│ calls
|
||||||
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
||||||
```
|
```
|
||||||
|
|
||||||
Delete `skills/sync` and the domain layer keeps working — issues that live only
|
Delete `skills/sync` and the issue domain keeps working. Work that lives only
|
||||||
on your machine are first-class, not drafts waiting to be uploaded. That is the
|
on your machine is first-class, not a draft waiting to be uploaded. That is the
|
||||||
point of the split: you can plan, write, validate, and track work without a
|
point of the split: you can plan, write, and validate without a tracker, and
|
||||||
tracker, and publish only what you choose to.
|
publish only what you choose to.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -68,7 +68,9 @@ The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-gua
|
|||||||
|
|
||||||
## First use
|
## First use
|
||||||
|
|
||||||
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to `.claude/settings.local.json` and takes effect immediately — no restart needed.
|
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to the project root's `.claude/settings.local.json` and takes effect immediately — no restart needed.
|
||||||
|
|
||||||
|
Once per *project*, not once per checkout: a `git worktree` shares its main checkout's pin. Both the hook and the scripts find it from inside a worktree, so don't run `/tea:auth` there — it would leave a second pin in a directory that disappears with the branch.
|
||||||
|
|
||||||
```
|
```
|
||||||
/tea:auth
|
/tea:auth
|
||||||
@@ -80,7 +82,7 @@ right skill automatically. `/tea:auth` is only needed for the tracker side;
|
|||||||
|
|
||||||
## How the login guard works
|
## How the login guard works
|
||||||
|
|
||||||
Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it.
|
Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. The hook and the scripts look it up the same way — one search order, in `skills/auth/scripts/pin.py`.
|
||||||
|
|
||||||
Claude is **blocked** from:
|
Claude is **blocked** from:
|
||||||
- running `tea` without `--login` at all
|
- running `tea` without `--login` at all
|
||||||
@@ -118,11 +120,15 @@ session — the pinned login is enforced on every call it makes.
|
|||||||
agents/
|
agents/
|
||||||
tea-runner.md subagent (Haiku) that executes the scripts
|
tea-runner.md subagent (Haiku) that executes the scripts
|
||||||
hooks/
|
hooks/
|
||||||
hooks.json registers the PreToolUse hook
|
hooks.json registers the PreToolUse hooks
|
||||||
tea-guard.sh the guard (Python 3, no deps)
|
tea-guard.sh the guard (Python 3, no deps)
|
||||||
|
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
|
||||||
skills/
|
skills/
|
||||||
auth/SKILL.md /tea:auth skill
|
auth/ /tea:auth — the identity layer
|
||||||
issue/ /tea:issue — the domain layer, offline
|
SKILL.md
|
||||||
|
scripts/pin.py where the login pin is and how it is found —
|
||||||
|
imported by _gitea.py AND by tea-guard.sh
|
||||||
|
issue/ /tea:issue — the issue domain, offline
|
||||||
SKILL.md
|
SKILL.md
|
||||||
references/format.md canonical issue format (identity, types, templates)
|
references/format.md canonical issue format (identity, types, templates)
|
||||||
scripts/ Python 3, stdlib only, no network:
|
scripts/ Python 3, stdlib only, no network:
|
||||||
@@ -133,6 +139,7 @@ skills/
|
|||||||
issue_check.py validate against the format
|
issue_check.py validate against the format
|
||||||
issue_ac.py list the body's checkboxes; tick one
|
issue_ac.py list the body's checkboxes; tick one
|
||||||
issue_tree.py draw the dependency graph
|
issue_tree.py draw the dependency graph
|
||||||
|
issue_evict.py drop closed issues the tracker also has
|
||||||
issue_index.py rebuild tmp/issues/INDEX.md
|
issue_index.py rebuild tmp/issues/INDEX.md
|
||||||
sync/ /tea:sync — the bridge to Gitea
|
sync/ /tea:sync — the bridge to Gitea
|
||||||
SKILL.md
|
SKILL.md
|
||||||
@@ -140,26 +147,40 @@ skills/
|
|||||||
map.py md <-> Gitea JSON, pure functions, no I/O
|
map.py md <-> Gitea JSON, pure functions, no I/O
|
||||||
_gitea.py transport: login pin, tea api, pagination, filters
|
_gitea.py transport: login pin, tea api, pagination, filters
|
||||||
pull.py Gitea -> tmp/issues/
|
pull.py Gitea -> tmp/issues/
|
||||||
push.py tmp/issues/ -> Gitea (additive; never deletes)
|
push.py tmp/issues/ -> Gitea, then drops the local file
|
||||||
remote.py discovery listing to stdout
|
remote.py discovery listing to stdout
|
||||||
comment.py post or edit a comment
|
comment.py post or edit a comment
|
||||||
|
close.py the state field, both ways
|
||||||
|
evict.py refresh state: from Gitea, then evict
|
||||||
|
labels.py put the canonical label set into a repository
|
||||||
use/ /tea:use — tea CLI reference (non-issue entities)
|
use/ /tea:use — tea CLI reference (non-issue entities)
|
||||||
SKILL.md
|
SKILL.md
|
||||||
references/tea/ command docs
|
references/tea/ command docs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`AGENTS.md` carries the same layout with the reasoning behind it; if the two
|
||||||
|
ever disagree, `AGENTS.md` is the one being worked from.
|
||||||
|
|
||||||
## Local issue store
|
## Local issue store
|
||||||
|
|
||||||
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
|
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
|
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
|
||||||
a parser.
|
a parser.
|
||||||
|
|
||||||
It is **the store, not a cache of Gitea**:
|
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
|
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
|
||||||
live in a `gitea:` field.
|
live in a `gitea:` field.
|
||||||
- `origin: local` is a durable state. An issue that never leaves your machine is
|
- `origin: local` is a complete state. An issue that never leaves your machine
|
||||||
complete and valid.
|
is valid and finished — but it is not permanent: pushing ends it.
|
||||||
- Pushing is additive — the file gains `gitea:` / `url:` / `synced:` and stays
|
- **A successful push deletes the local file** (`--update` too) and prints the
|
||||||
put. Pulling overwrites the body: a fetch, not a merge.
|
number and URL it now lives at. Only after a confirmed response: a failed
|
||||||
- Nothing tracks drift. `synced:` tells you how old your copy is.
|
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.
|
||||||
|
|||||||
+28
-14
@@ -29,11 +29,10 @@ to fill the gap yourself.
|
|||||||
|
|
||||||
Load the skill, do not remember the flags:
|
Load the skill, do not remember the flags:
|
||||||
|
|
||||||
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `remote.py`, `labels.py`
|
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `close.py`, `remote.py`,
|
||||||
|
`labels.py`, `evict.py`
|
||||||
- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`,
|
- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`,
|
||||||
`issue_new.py`, `issue_ac.py`
|
`issue_new.py`, `issue_ac.py`, `issue_evict.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
|
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
|
table it gives you verbatim. The skill is the single source of
|
||||||
@@ -44,8 +43,7 @@ instead of trying it.
|
|||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
1. **No raw `tea`.** Every tracker call goes through a script in
|
1. **No raw `tea`.** Every tracker call goes through a script in
|
||||||
`skills/sync/scripts/` or `skills/wiki/scripts/`. The one exception is a
|
`skills/sync/scripts/`. The one exception is a diagnostic the skill itself
|
||||||
diagnostic the skill itself
|
|
||||||
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
|
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
|
||||||
the `tea-guard` hook substitutes the pinned login. Never name a login.
|
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
|
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
|
||||||
@@ -55,14 +53,29 @@ instead of trying it.
|
|||||||
only the items the caller named, by the number or the substring the caller
|
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
|
gave. Whether a criterion is actually met is a judgement about content, and
|
||||||
content is never yours.
|
content is never yours.
|
||||||
3. **Push only what you were told to push.** `push.py` and `wiki_push.py`
|
3. **Push only what you were told to push.** `push.py` publishes to a tracker
|
||||||
publish to a tracker other people read. Run them with the ids, titles, or
|
other people read, **and it deletes the local file on success** — so a
|
||||||
filter the caller named. Never widen the set, never run a bare `push.py`
|
widened set is not an over-share, it is somebody else's working copy gone.
|
||||||
because it looked like the obvious next step, and never pass `--force` — a
|
Run it with the ids, titles, or filter the caller named. Never widen the
|
||||||
validation failure is a result to report, not an obstacle to route around.
|
set, never run a bare `push.py` because it looked like the obvious next
|
||||||
`wiki_push.py` needs `-m`; use the caller's words, never your own summary.
|
step, and never pass `--force` — a validation failure is a result to report,
|
||||||
4. **Do not close, delete, or retitle anything** on either side. On the wiki
|
not an obstacle to route around. Report the number and URL `push.py`
|
||||||
that means no `--retitle`: renaming a published page abandons the old one.
|
printed; that is now the only address the issue has.
|
||||||
|
4. **Close only the ids the caller named.** Closing is a script now
|
||||||
|
(`close.py`), so it is yours to run — under the same discipline as push: the
|
||||||
|
ids the caller named, and no others. Never widen the set, never infer that
|
||||||
|
an issue is finished because its checkboxes are ticked or its branch is
|
||||||
|
merged; whether work is done is a judgement about content, and content is
|
||||||
|
never yours. `--reopen` is the same rule backwards. **Retitling stays
|
||||||
|
forbidden**, and deleting anything on a tracker is never yours either.
|
||||||
|
|
||||||
|
Two local deletions are allowed, both only when the caller asked for them:
|
||||||
|
push's own, on the issue you were told to push, and eviction
|
||||||
|
(`issue_evict.py` / `evict.py`) of closed issues. Run eviction with
|
||||||
|
`--dry-run` first and report what it named; never widen the set past what
|
||||||
|
the caller said. It refuses to touch an `origin: local` issue by itself —
|
||||||
|
that is the script's guarantee, not your judgement, and it is not a reason
|
||||||
|
to point it at a store nobody asked you to clean.
|
||||||
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
||||||
permute flags looking for one that works.
|
permute flags looking for one that works.
|
||||||
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
|
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
|
||||||
@@ -116,3 +129,4 @@ Report these and halt; none of them is yours to resolve.
|
|||||||
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
|
| 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 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 |
|
| a script asks for a decision (type, label, `--force`) | `blocked:` with the question |
|
||||||
|
| `close.py` is refused by Gitea because the issue is still blocked | the tracker's own line, and the blocker's number; the caller decides |
|
||||||
|
|||||||
+204
-57
@@ -12,7 +12,12 @@ checking it:
|
|||||||
|
|
||||||
The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call
|
The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call
|
||||||
time — from the FILE, not the environment — so a freshly pinned login works in
|
time — from the FILE, not the environment — so a freshly pinned login works in
|
||||||
the same session with no restart.
|
the same session with no restart. WHERE that file is looked for is not decided
|
||||||
|
here: skills/auth/scripts/pin.py holds the search order, and the sync
|
||||||
|
scripts resolve the pin through the same module. One order, one copy of it. The
|
||||||
|
guard and the scripts disagreeing about a directory is a bug by construction,
|
||||||
|
and was one: in a git worktree `tea` worked and every script said "no login
|
||||||
|
pinned".
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- not a `tea` command ............................. allow (passthrough)
|
- not a `tea` command ............................. allow (passthrough)
|
||||||
@@ -22,13 +27,171 @@ Rules:
|
|||||||
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
|
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
|
||||||
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
|
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
|
||||||
|
|
||||||
|
"A `tea` command" means the shell would RUN `tea`, not that the string contains
|
||||||
|
the word. The guard used to ask the second question — a substring search over
|
||||||
|
the whole command line — and in a repository whose subject *is* the CLI that is
|
||||||
|
a different question with the same answer far too often: an issue title, a
|
||||||
|
commit message, `grep -rn " tea " docs/` and `echo tea` were all blocked, with
|
||||||
|
a message telling the operator to add `--login` to `git commit`. Worse, the
|
||||||
|
advice was unfollowable: the only way past the guard was to reword the prose.
|
||||||
|
|
||||||
|
So the command is tokenized (heredoc bodies dropped, line continuations
|
||||||
|
folded, backticks and newlines treated as boundaries) and only words in
|
||||||
|
*command position* count — the first word, and the first word after `;`, `&&`,
|
||||||
|
`||`, `|`, `&`, `(`, `)`, `{`, `}`, past any VAR=value assignments and prefix
|
||||||
|
words like `env`/`sudo`/`xargs`. Quoting is what saves the prose: a title or a
|
||||||
|
`-m` message is one token, and one token is never a command. Compound commands
|
||||||
|
stay guarded segment by segment, substitutions included, and every `tea` in the
|
||||||
|
line is checked — not just the first.
|
||||||
|
|
||||||
|
If the line cannot be tokenized at all (unbalanced quotes), the old substring
|
||||||
|
test decides. That direction fails closed: it over-matches, and over-matching
|
||||||
|
blocks.
|
||||||
|
|
||||||
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
|
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
|
||||||
rewrite; exit 2 + stderr to block.
|
rewrite; exit 2 + stderr to block.
|
||||||
"""
|
"""
|
||||||
import sys, os, re, json, shlex
|
import sys, os, re, json, shlex
|
||||||
|
|
||||||
|
# The identity layer, reached by the plugin's own layout — the one thing a hook
|
||||||
|
# may assume about where it lives. Import failure is not fatal on its own: a
|
||||||
|
# command that is not `tea` still passes through untouched (see main), and only
|
||||||
|
# a command that needs a login is blocked.
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
os.pardir, "skills", "auth", "scripts")))
|
||||||
|
try:
|
||||||
|
import pin
|
||||||
|
except Exception:
|
||||||
|
pin = None
|
||||||
|
|
||||||
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
|
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
|
||||||
|
|
||||||
|
# Operators after which the next word is a command again.
|
||||||
|
SEPARATORS = {";", ";;", "&", "&&", "|", "|&", "||", "(", ")", "{", "}"}
|
||||||
|
# Words that stand in front of a command without being one.
|
||||||
|
TRANSPARENT = {"env", "command", "exec", "nohup", "time", "sudo", "xargs",
|
||||||
|
"if", "then", "else", "elif", "while", "until", "do", "!"}
|
||||||
|
|
||||||
|
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||||
|
REDIRECT = re.compile(r"^\d*[<>]+&?\d*-?$")
|
||||||
|
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
||||||
|
# A login flag and its value, in the ORIGINAL text — this is what gets
|
||||||
|
# rewritten, so it works on the raw string rather than on tokens.
|
||||||
|
LOGIN_FLAG = re.compile(r"(--login|(?<![\w-])-l)(\s+|=)(\S+)")
|
||||||
|
# The pre-tokenizer test, kept for the one case tokenizing cannot serve.
|
||||||
|
LOOKS_LIKE_TEA = re.compile(r"(^|[;&|(]|\s)tea(\s|$)")
|
||||||
|
|
||||||
|
NO_LOGIN = ('every `tea` command must include --login "$GITEA_LOGIN" '
|
||||||
|
'(the guard substitutes the operator-pinned login). '
|
||||||
|
'Run /tea:auth if no login is pinned.')
|
||||||
|
|
||||||
|
|
||||||
|
def named_login(raw):
|
||||||
|
return ('do not name the login yourself (got `%s`). Write exactly '
|
||||||
|
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
|
||||||
|
'the operator pinned via /tea:auth. This prevents acting under '
|
||||||
|
'the wrong identity.' % raw)
|
||||||
|
|
||||||
|
|
||||||
|
def unquote(value):
|
||||||
|
for q in ('"', "'"):
|
||||||
|
if len(value) >= 2 and value[0] == q and value[-1] == q:
|
||||||
|
return value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def strip_heredocs(cmd):
|
||||||
|
"""Drop heredoc bodies. They are data the shell feeds to a command, not
|
||||||
|
commands — and a commit message quoting a raw `tea api` call is exactly the
|
||||||
|
thing that used to be unwritable."""
|
||||||
|
lines, kept, i = cmd.split("\n"), [], 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
kept.append(line)
|
||||||
|
i += 1
|
||||||
|
for m in HEREDOC.finditer(line):
|
||||||
|
delim, dash = m.group(2), m.group(0).startswith("<<-")
|
||||||
|
while i < len(lines):
|
||||||
|
probe = lines[i].strip() if dash else lines[i].rstrip()
|
||||||
|
i += 1
|
||||||
|
if probe == delim:
|
||||||
|
break
|
||||||
|
return "\n".join(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def shell_words(cmd):
|
||||||
|
"""Tokens, with operators as tokens of their own and quotes honored.
|
||||||
|
|
||||||
|
Backticks and newlines become separators before tokenizing: shlex knows
|
||||||
|
neither, and both start a command. Inside quotes that substitution is
|
||||||
|
harmless — the token still spans the quotes, and a token is never a
|
||||||
|
command."""
|
||||||
|
text = strip_heredocs(cmd)
|
||||||
|
text = re.sub(r"\\\n", " ", text)
|
||||||
|
text = text.replace("`", " ; ").replace("\n", " ; ")
|
||||||
|
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
|
||||||
|
lex.whitespace_split = True
|
||||||
|
return list(lex)
|
||||||
|
|
||||||
|
|
||||||
|
def tea_invocations(words):
|
||||||
|
"""The argument list of every `tea` the shell would actually run."""
|
||||||
|
found, current, expect, skip = [], None, True, False
|
||||||
|
for w in words:
|
||||||
|
if skip:
|
||||||
|
skip = False
|
||||||
|
continue
|
||||||
|
if REDIRECT.match(w):
|
||||||
|
skip = True # the target of a redirection is not a command
|
||||||
|
continue
|
||||||
|
if w in SEPARATORS:
|
||||||
|
current, expect = None, True
|
||||||
|
continue
|
||||||
|
if expect:
|
||||||
|
if ASSIGNMENT.match(w) or w in TRANSPARENT:
|
||||||
|
continue
|
||||||
|
expect = False
|
||||||
|
if w.rsplit("/", 1)[-1] == "tea":
|
||||||
|
current = []
|
||||||
|
found.append(current)
|
||||||
|
continue
|
||||||
|
if current is not None:
|
||||||
|
current.append(w)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def is_meta(args):
|
||||||
|
"""Login enumeration and `--version`/`--help`: no identity is used, and
|
||||||
|
/tea:auth needs `tea logins list` while no pin exists yet."""
|
||||||
|
if not args:
|
||||||
|
return False
|
||||||
|
if args[0] in ("--version", "-v", "--help", "-h", "help"):
|
||||||
|
return True
|
||||||
|
return args[0] in ("logins", "login") and len(args) > 1 \
|
||||||
|
and args[1] in ("list", "ls")
|
||||||
|
|
||||||
|
|
||||||
|
def login_value(args):
|
||||||
|
"""The login as written, or None if the flag is absent."""
|
||||||
|
for i, a in enumerate(args):
|
||||||
|
if a in ("--login", "-l"):
|
||||||
|
return args[i + 1] if i + 1 < len(args) else ""
|
||||||
|
if a.startswith("--login=") or a.startswith("-l="):
|
||||||
|
return a.split("=", 1)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def substitute(cmd, login):
|
||||||
|
"""Every placeholder login in the line, replaced by the pin. Every one:
|
||||||
|
a command may run `tea` twice, and half a rewrite leaves the second call
|
||||||
|
with an unset variable and no login at all."""
|
||||||
|
def repl(m):
|
||||||
|
if unquote(m.group(3)) in PLACEHOLDERS:
|
||||||
|
return m.group(1) + m.group(2) + shlex.quote(login)
|
||||||
|
return m.group(0)
|
||||||
|
return LOGIN_FLAG.sub(repl, cmd)
|
||||||
|
|
||||||
|
|
||||||
def block(msg):
|
def block(msg):
|
||||||
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
|
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
|
||||||
@@ -53,30 +216,6 @@ def rewrite(tool_input, new_cmd, note):
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
def find_pin(start_dir):
|
|
||||||
"""Walk up from start_dir; return (login, path) from the first
|
|
||||||
.claude/settings.local.json that carries a non-empty env.GITEA_LOGIN."""
|
|
||||||
try:
|
|
||||||
d = os.path.abspath(start_dir or ".")
|
|
||||||
except Exception:
|
|
||||||
return None, None
|
|
||||||
while True:
|
|
||||||
p = os.path.join(d, ".claude", "settings.local.json")
|
|
||||||
if os.path.isfile(p):
|
|
||||||
try:
|
|
||||||
with open(p) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
v = (data.get("env") or {}).get("GITEA_LOGIN")
|
|
||||||
if isinstance(v, str) and v.strip():
|
|
||||||
return v.strip(), p
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
parent = os.path.dirname(d)
|
|
||||||
if parent == d:
|
|
||||||
return None, None
|
|
||||||
d = parent
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.load(sys.stdin)
|
||||||
@@ -88,45 +227,53 @@ def main():
|
|||||||
tool_input = payload.get("tool_input") or {}
|
tool_input = payload.get("tool_input") or {}
|
||||||
cmd = tool_input.get("command") or ""
|
cmd = tool_input.get("command") or ""
|
||||||
|
|
||||||
# Not a `tea` invocation → not our concern.
|
try:
|
||||||
if not re.search(r'(^|[;&|(]|\s)tea(\s|$)', cmd):
|
runs = tea_invocations(shell_words(cmd))
|
||||||
allow_passthrough()
|
except ValueError:
|
||||||
|
# Unbalanced quotes: what the shell would run is not knowable here.
|
||||||
|
# Fall back to the substring test — it over-matches, and over-matching
|
||||||
|
# blocks rather than lets an unpinned call through.
|
||||||
|
runs = None
|
||||||
|
|
||||||
# Whitelist: login enumeration + meta. No identity is used; /tea:auth
|
if runs is None:
|
||||||
# needs `tea logins list` while no pin exists yet.
|
if not LOOKS_LIKE_TEA.search(cmd):
|
||||||
if re.search(r'tea\s+(logins\s+(list|ls)|--version|-v|--help|help)(\s|$)', cmd):
|
allow_passthrough()
|
||||||
allow_passthrough()
|
m = LOGIN_FLAG.search(cmd)
|
||||||
|
if not m:
|
||||||
|
block(NO_LOGIN)
|
||||||
|
if unquote(m.group(3)) not in PLACEHOLDERS:
|
||||||
|
block(named_login(m.group(3)))
|
||||||
|
else:
|
||||||
|
# The word appears but nothing runs it → not our concern. This is the
|
||||||
|
# branch that lets prose about the CLI be written at all.
|
||||||
|
if not runs:
|
||||||
|
allow_passthrough()
|
||||||
|
for args in runs:
|
||||||
|
if is_meta(args):
|
||||||
|
continue
|
||||||
|
raw = login_value(args)
|
||||||
|
if raw is None:
|
||||||
|
block(NO_LOGIN)
|
||||||
|
if unquote(raw) not in PLACEHOLDERS:
|
||||||
|
block(named_login(raw))
|
||||||
|
if all(is_meta(args) for args in runs):
|
||||||
|
allow_passthrough()
|
||||||
|
|
||||||
# Locate --login / -l and its value (logins never contain spaces).
|
if pin is None:
|
||||||
m = re.search(r'(--login|(?<![\w-])-l)(\s+|=)(\S+)', cmd)
|
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
|
||||||
if not m:
|
'cannot be resolved. The plugin tree is incomplete; reinstall it.')
|
||||||
block('every `tea` command must include --login "$GITEA_LOGIN" '
|
|
||||||
'(the guard substitutes the operator-pinned login). '
|
|
||||||
'Run /tea:auth if no login is pinned.')
|
|
||||||
|
|
||||||
raw_val = m.group(3)
|
# The hint is the directory the Bash command will run in; the rest of the
|
||||||
inner = raw_val
|
# order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the
|
||||||
for q in ('"', "'"):
|
# search) is pin.py's, and is the same order the scripts get.
|
||||||
if len(inner) >= 2 and inner[0] == q and inner[-1] == q:
|
login, src = pin.find_pin(payload.get("cwd"))
|
||||||
inner = inner[1:-1]
|
if not login:
|
||||||
break
|
|
||||||
|
|
||||||
if inner not in PLACEHOLDERS:
|
|
||||||
block('do not name the login yourself (got `%s`). Write exactly '
|
|
||||||
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
|
|
||||||
'the operator pinned via /tea:auth. This prevents acting under '
|
|
||||||
'the wrong identity.' % raw_val)
|
|
||||||
|
|
||||||
start = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
|
||||||
pin, src = find_pin(start)
|
|
||||||
if not pin:
|
|
||||||
block('no login is pinned. Run /tea:auth to choose one (writes '
|
block('no login is pinned. Run /tea:auth to choose one (writes '
|
||||||
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
||||||
'the file at call time, so it takes effect with no restart.')
|
'the file at call time, so it takes effect with no restart.')
|
||||||
|
|
||||||
new_cmd = cmd[:m.start(3)] + shlex.quote(pin) + cmd[m.end(3):]
|
rewrite(tool_input, substitute(cmd, login),
|
||||||
rewrite(tool_input, new_cmd,
|
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
|
||||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (pin, src))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+27
-3
@@ -32,13 +32,37 @@ So:
|
|||||||
3. **One login:** propose it; confirm before writing.
|
3. **One login:** propose it; confirm before writing.
|
||||||
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
|
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
|
||||||
`url` so the operator's choice is unambiguous. Never decide for them.
|
`url` so the operator's choice is unambiguous. Never decide for them.
|
||||||
5. Merge the chosen name into `.claude/settings.local.json` under `env`
|
5. Merge the chosen name into the **project root's**
|
||||||
(do not clobber other keys):
|
`.claude/settings.local.json` under `env` (do not clobber other keys):
|
||||||
```json
|
```json
|
||||||
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
|
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
|
||||||
```
|
```
|
||||||
|
**In a git worktree, write it to the main checkout, never to the worktree.**
|
||||||
|
A worktree is deleted when the branch is done, taking a pin written into it
|
||||||
|
with it, and one repository with two pins is one repository with two
|
||||||
|
identities. Both the guard and the scripts already reach the main checkout's
|
||||||
|
pin from inside any worktree — so there is nothing to pin a second time.
|
||||||
|
`git rev-parse --path-format=absolute --git-common-dir` names the `.git` to
|
||||||
|
write beside.
|
||||||
6. Done — it is live. The guard resolves the pin from the file on the next
|
6. Done — it is live. The guard resolves the pin from the file on the next
|
||||||
`tea` call; no restart needed. Tell the operator which login is now pinned.
|
`tea` call; no restart needed. Tell the operator which login is now pinned,
|
||||||
|
and which file it went in.
|
||||||
|
|
||||||
|
## Where the pin is looked for
|
||||||
|
|
||||||
|
One search order, written once in `scripts/pin.py` and imported by both the
|
||||||
|
`tea-guard` hook and the sync transport — they cannot disagree about a
|
||||||
|
directory, and a test asserts neither keeps a copy of the walk.
|
||||||
|
|
||||||
|
`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's
|
||||||
|
`cwd`), then the current directory. Each is searched up its parent chain; only
|
||||||
|
if that finds nothing does the search cross into the main working tree of a
|
||||||
|
linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory
|
||||||
|
is never a source — a plugin pointed at somebody else's project must take the
|
||||||
|
identity from that project, not from where it happens to be installed.
|
||||||
|
|
||||||
|
If a script reports "no login pinned", that is the honest answer: nothing was
|
||||||
|
found anywhere on that order. Pin one — at the project root.
|
||||||
|
|
||||||
## Identity-safety rules
|
## Identity-safety rules
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
pin.py — where the operator's Gitea login pin is, and how it is found.
|
||||||
|
|
||||||
|
**The search order lives here and nowhere else.** The `tea-guard` hook imports
|
||||||
|
this module; so does the transport every sync script runs on. Two
|
||||||
|
copies of the order is exactly how a git worktree came to have a working hook
|
||||||
|
and a dead transport in the same directory: `tea` resolved the login, the
|
||||||
|
scripts said "no login pinned", and the error told the operator to pin what was
|
||||||
|
already pinned.
|
||||||
|
|
||||||
|
Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse
|
||||||
|
hook runs before every Bash call and must not fork a process to answer this.
|
||||||
|
|
||||||
|
The pin is a file the OPERATOR owns and `/tea:auth` writes:
|
||||||
|
|
||||||
|
<project root>/.claude/settings.local.json -> env.GITEA_LOGIN
|
||||||
|
|
||||||
|
## Search order
|
||||||
|
|
||||||
|
Start directories, in order, first hit wins:
|
||||||
|
|
||||||
|
1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set
|
||||||
|
2. an explicit hint the hook passes the Bash tool's cwd; scripts pass
|
||||||
|
nothing and go straight to 3
|
||||||
|
3. the current directory
|
||||||
|
|
||||||
|
Each start directory is searched the same way:
|
||||||
|
|
||||||
|
a. up the parent chain, from the directory itself to the filesystem root
|
||||||
|
b. then, for each LINKED WORKTREE seen on that chain, up the parent chain
|
||||||
|
of that repository's main working tree
|
||||||
|
|
||||||
|
(b) is the whole point of this module. A worktree is a *sibling* of the main
|
||||||
|
checkout, not a descendant, so `.claude/settings.local.json` — untracked, and
|
||||||
|
therefore only ever in the main checkout — is not on the parent chain of (a).
|
||||||
|
Git knows the two trees are one repository: a worktree's `.git` is a FILE
|
||||||
|
holding `gitdir: <path>`, and `<path>/commondir` points back at the shared
|
||||||
|
`.git`. `git rev-parse --git-common-dir` answers the same question by forking;
|
||||||
|
this reads the files.
|
||||||
|
|
||||||
|
## Why the search does not start at __file__
|
||||||
|
|
||||||
|
Deliberate asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which
|
||||||
|
*are* anchored on their own module's location. Two different questions:
|
||||||
|
|
||||||
|
where does this installation keep its files a fact about the plugin
|
||||||
|
whose login does this project run under a fact about the project
|
||||||
|
|
||||||
|
A plugin installed outside any repository and pointed at somebody else's tree
|
||||||
|
must answer the second one from the tree it was pointed at. Anchoring the pin
|
||||||
|
on `__file__` would make the plugin's own directory an identity source, which
|
||||||
|
is how a checkout ends up acting under a login nobody chose for it. So the
|
||||||
|
search runs from the working directory upward — and reaches a worktree's main
|
||||||
|
checkout by asking git, not by walking somewhere else.
|
||||||
|
|
||||||
|
Finding nothing is a real answer: `(None, None)` means there is no pin, and the
|
||||||
|
caller says so. This module never guesses a login.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
SETTINGS_PARTS = (".claude", "settings.local.json")
|
||||||
|
ENV_KEY = "GITEA_LOGIN"
|
||||||
|
PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR"
|
||||||
|
|
||||||
|
|
||||||
|
def settings_path(root):
|
||||||
|
"""The pin file for a project root. The one place this path is spelled."""
|
||||||
|
return os.path.join(root, *SETTINGS_PARTS)
|
||||||
|
|
||||||
|
|
||||||
|
def read_pin(path):
|
||||||
|
"""The login in a settings file, or None.
|
||||||
|
|
||||||
|
Unreadable, not JSON, no `env`, empty string — all the same answer. A
|
||||||
|
broken file is not a login and is not worth a traceback in a hook."""
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
value = (json.load(f).get("env") or {}).get(ENV_KEY)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parents(start):
|
||||||
|
"""`start` and every ancestor of it, up to the filesystem root."""
|
||||||
|
d = os.path.abspath(start)
|
||||||
|
while True:
|
||||||
|
yield d
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
return
|
||||||
|
d = parent
|
||||||
|
|
||||||
|
|
||||||
|
def gitdir_of(d):
|
||||||
|
"""The private git directory `d/.git` points at, or None.
|
||||||
|
|
||||||
|
Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a
|
||||||
|
directory and there is nothing to follow."""
|
||||||
|
p = os.path.join(d, ".git")
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(p) as f:
|
||||||
|
head = f.read(4096)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
for line in head.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("gitdir:"):
|
||||||
|
target = line[len("gitdir:"):].strip()
|
||||||
|
if not target:
|
||||||
|
return None
|
||||||
|
if not os.path.isabs(target):
|
||||||
|
target = os.path.join(d, target)
|
||||||
|
return os.path.abspath(target)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main_worktree(d):
|
||||||
|
"""If `d` is a linked worktree, the main working tree of its repository.
|
||||||
|
|
||||||
|
`<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir`
|
||||||
|
file holds a path to `<main>/.git`; the main working tree is its parent.
|
||||||
|
The `.git` basename check keeps this to worktrees: a submodule's `.git`
|
||||||
|
is a pointer too, but it points into `<super>/.git/modules/…`, and the
|
||||||
|
tree it belongs to is already on the parent chain."""
|
||||||
|
gitdir = gitdir_of(d)
|
||||||
|
if not gitdir or not os.path.isdir(gitdir):
|
||||||
|
return None
|
||||||
|
common = gitdir
|
||||||
|
marker = os.path.join(gitdir, "commondir")
|
||||||
|
if os.path.isfile(marker):
|
||||||
|
try:
|
||||||
|
with open(marker) as f:
|
||||||
|
rel = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
rel = ""
|
||||||
|
if rel:
|
||||||
|
common = os.path.abspath(os.path.join(gitdir, rel))
|
||||||
|
if os.path.basename(common) != ".git":
|
||||||
|
return None
|
||||||
|
root = os.path.dirname(common)
|
||||||
|
if root and os.path.isdir(root) and root != os.path.abspath(d):
|
||||||
|
return root
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def search(start):
|
||||||
|
"""(login, path) for one start directory: the parent chain, then the main
|
||||||
|
checkout of any worktree met on it. (None, None) when there is no pin.
|
||||||
|
|
||||||
|
The chain comes first and always wins, so the worktree branch can only
|
||||||
|
ever find a pin that walking up would not have found at all."""
|
||||||
|
hops = []
|
||||||
|
for d in parents(start):
|
||||||
|
login = read_pin(settings_path(d))
|
||||||
|
if login:
|
||||||
|
return login, settings_path(d)
|
||||||
|
root = main_worktree(d)
|
||||||
|
if root and root not in hops:
|
||||||
|
hops.append(root)
|
||||||
|
for root in hops:
|
||||||
|
# One level of indirection, never two: a main checkout is not itself a
|
||||||
|
# linked worktree, so this loop cannot chain and cannot cycle.
|
||||||
|
for d in parents(root):
|
||||||
|
login = read_pin(settings_path(d))
|
||||||
|
if login:
|
||||||
|
return login, settings_path(d)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def start_dirs(hint=None):
|
||||||
|
"""The ordered, deduplicated start directories.
|
||||||
|
|
||||||
|
`hint` is the caller's own idea of where the work is happening — the hook
|
||||||
|
passes the `cwd` from its payload, which is the directory the Bash command
|
||||||
|
will actually run in. A script has no payload and passes nothing."""
|
||||||
|
try:
|
||||||
|
cwd = os.getcwd()
|
||||||
|
except OSError: # cwd deleted out from under us
|
||||||
|
cwd = None
|
||||||
|
out = []
|
||||||
|
for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd):
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
d = os.path.abspath(d)
|
||||||
|
if d not in out:
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_pin(hint=None):
|
||||||
|
"""(login, path) for the first start directory that has a pin, else
|
||||||
|
(None, None). The entry point; everything above is its parts."""
|
||||||
|
for start in start_dirs(hint):
|
||||||
|
login, path = search(start)
|
||||||
|
if login:
|
||||||
|
return login, path
|
||||||
|
return None, None
|
||||||
+52
-2
@@ -38,6 +38,7 @@ All offline, all in `<skill-base-dir>/scripts/`.
|
|||||||
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
|
| `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_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_tree.py [id…]` | draw the dependency graph from `depends:` |
|
||||||
|
| `issue_evict.py [id…] [--dry-run]` | remove closed issues from the store; **never** an `origin: local` one |
|
||||||
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
|
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
|
||||||
| `issue.py` | the domain module the others import — not a command |
|
| `issue.py` | the domain module the others import — not a command |
|
||||||
|
|
||||||
@@ -115,8 +116,13 @@ 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
|
`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh
|
||||||
the table. Checkboxes are the exception — use `issue_ac.py`, below.
|
the table. Checkboxes are the exception — use `issue_ac.py`, below.
|
||||||
|
|
||||||
If the issue is synced (`origin: gitea`), your edit is local until you run
|
If the issue is synced (`origin: gitea`), the file is a working copy: your edit
|
||||||
`push.py --update` from `/tea:sync`. Nothing tracks that drift automatically.
|
is local until you run `push.py --update` from `/tea:sync`, and that push
|
||||||
|
**deletes the file** once Gitea has it. Closing one of those is `close.py` from
|
||||||
|
`/tea:sync` — it moves the state on both sides in a single run; editing
|
||||||
|
`state:` here alone would only ever tell this machine. 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
|
## Ticking checkboxes
|
||||||
|
|
||||||
@@ -202,6 +208,50 @@ 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`
|
the rewritten body into the tracker is a separate decision — `push.py --update`
|
||||||
in `/tea:sync` — and is no part of this.
|
in `/tea:sync` — and is no part of this.
|
||||||
|
|
||||||
|
## Evicting closed issues
|
||||||
|
|
||||||
|
The store is a working set, not an archive. A closed issue is not a unit of
|
||||||
|
work any more, and one command takes it out — no `rm`, no rebuilding `INDEX.md`
|
||||||
|
by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-base-dir>/scripts/issue_evict.py --dry-run # what would go
|
||||||
|
python3 <skill-base-dir>/scripts/issue_evict.py # every closed one
|
||||||
|
python3 <skill-base-dir>/scripts/issue_evict.py old-thing # just this one
|
||||||
|
```
|
||||||
|
|
||||||
|
Two conditions, both read off the file, and the second one is the whole safety
|
||||||
|
argument:
|
||||||
|
|
||||||
|
| `state:` | `origin:` | what eviction does |
|
||||||
|
|---|---|---|
|
||||||
|
| `closed` | a tracker | removes `<id>.md` and every sidecar under that slug |
|
||||||
|
| `closed` | `local` | **keeps it, always**, and says why |
|
||||||
|
| `open` | anything | keeps it |
|
||||||
|
|
||||||
|
**`origin: local` is never evicted, in any state, not even when you name it on
|
||||||
|
the command line.** That file *is* the issue; there is no copy to fetch back.
|
||||||
|
Only a file whose own metadata says the work lives somewhere else may go — the
|
||||||
|
same trade `push.py` makes when it drops a file the tracker just confirmed.
|
||||||
|
|
||||||
|
- `--dry-run` prints what would go and writes nothing at all, `INDEX.md`
|
||||||
|
included.
|
||||||
|
- `INDEX.md` is rebuilt afterwards, so the table and the directory agree. It is
|
||||||
|
rebuilt only when something was actually removed.
|
||||||
|
- `.remote.json` is **not** pruned, deliberately: it is the number → slug
|
||||||
|
ledger, and its entries are supposed to outlive the files they name (that is
|
||||||
|
what makes `pull.py <n>` land on the same slug after a push). An evicted issue
|
||||||
|
is in exactly the state a pushed one is.
|
||||||
|
- **This is not a one-off migration.** `pull.py <n>` fetches an issue in any
|
||||||
|
state — a number is an address, not a query — so a closed issue pulled after
|
||||||
|
an eviction lands on disk again. Not a regression: evict it again when you are
|
||||||
|
done reading it.
|
||||||
|
|
||||||
|
This command is offline and decides from `state:` in the file, which is only as
|
||||||
|
fresh as the last pull. To have the tracker's answer instead — an issue closed
|
||||||
|
in the web UI five minutes ago — use `/tea:sync`'s `evict.py`, which refreshes
|
||||||
|
`state:` first and then calls exactly this decision.
|
||||||
|
|
||||||
## Dependency graph
|
## Dependency graph
|
||||||
|
|
||||||
`depends:` is the authoritative edge list; the body's `## Depends on` section
|
`depends:` is the authoritative edge list; the body's `## Depends on` section
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
Canonical format for every issue in this project, whether it ever reaches a
|
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
|
tracker or not. Designed to be unambiguous for both humans and LLMs: fixed
|
||||||
English section headers in a fixed order, verifiable acceptance criteria, one
|
English section headers in a fixed order, verifiable acceptance criteria, one
|
||||||
issue = one deliverable. Source spec: the project wiki
|
issue = one deliverable.
|
||||||
([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
|
Nothing here depends on Gitea. How these files are mapped onto a tracker is the
|
||||||
sync layer's business — see `/tea:sync`.
|
sync layer's business — see `/tea:sync`.
|
||||||
@@ -14,13 +13,22 @@ sync layer's business — see `/tea:sync`.
|
|||||||
An issue is one file, `tmp/issues/<id>.md`, and `id` is a slug: lowercase
|
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
|
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
|
identity.** It is stable for the life of the issue — a retitled issue keeps its
|
||||||
slug, and an issue pushed to a tracker keeps it too. Tracker numbers are a
|
slug; an issue pushed to a tracker, deleted locally and fetched back a month
|
||||||
foreign key stored in a field, never the name of anything.
|
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
|
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
|
## Metadata block
|
||||||
|
|
||||||
One field per line, lists inline, so plain `grep` works without a parser:
|
One field per line, lists inline, so plain `grep` works without a parser:
|
||||||
@@ -33,7 +41,6 @@ labels: [type/task, tech/sql]
|
|||||||
assignees: [naudachu]
|
assignees: [naudachu]
|
||||||
milestone: v0.2
|
milestone: v0.2
|
||||||
depends: [migrate-schema]
|
depends: [migrate-schema]
|
||||||
wiki: [Simple Chains/Ideas/Chain core]
|
|
||||||
origin: gitea
|
origin: gitea
|
||||||
branch: feat/wire-sqlc
|
branch: feat/wire-sqlc
|
||||||
gitea: claude-skills/tea#42
|
gitea: claude-skills/tea#42
|
||||||
@@ -55,7 +62,6 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
|
|||||||
| `assignees` | domain | logins; may be empty |
|
| `assignees` | domain | logins; may be empty |
|
||||||
| `milestone` | domain | title, or `none` |
|
| `milestone` | domain | title, or `none` |
|
||||||
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
| `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 |
|
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
||||||
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
|
| `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 |
|
| `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 |
|
||||||
@@ -69,8 +75,30 @@ 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
|
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.
|
represent a local issue and a synced one without a second format.
|
||||||
|
|
||||||
`origin: local` is a **durable state, not a pending one.** An issue that never
|
`origin: local` is a **complete state, not a pending one.** An issue that never
|
||||||
leaves this machine is complete and valid. Pushing is optional and additive.
|
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 it is what the file's fate depends on:
|
||||||
|
|
||||||
|
| `origin:` | what the file is | what a push does to it | what eviction does to it |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
|
||||||
|
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**A closed issue is evicted from the store** by `issue_evict.py` — same trade,
|
||||||
|
one condition more: the work is done *and* it exists somewhere else. An
|
||||||
|
`origin: local` issue is never evicted, because there is nowhere to fetch it
|
||||||
|
back from. The store is a working set, not an archive; `pull.py <n>` fetches a
|
||||||
|
closed issue again whenever it is wanted.
|
||||||
|
|
||||||
|
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
|
## Language rules
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ ISSUE_ROOT = store_root()
|
|||||||
|
|
||||||
# Domain-owned metadata, in render order. Foreign keys render after these,
|
# Domain-owned metadata, in render order. Foreign keys render after these,
|
||||||
# sorted, so the sync layer can add fields without touching this list.
|
# sorted, so the sync layer can add fields without touching this list.
|
||||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"]
|
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
|
||||||
|
"origin"]
|
||||||
LIST_KEYS = {"labels", "assignees", "depends"}
|
LIST_KEYS = {"labels", "assignees", "depends"}
|
||||||
STATES = ("open", "closed")
|
STATES = ("open", "closed")
|
||||||
|
|
||||||
@@ -247,8 +248,8 @@ class Issue(object):
|
|||||||
"""One unit of work. `extra` holds metadata this layer does not own."""
|
"""One unit of work. `extra` holds metadata this layer does not own."""
|
||||||
|
|
||||||
def __init__(self, id="", title="", body="", state="open", labels=None,
|
def __init__(self, id="", title="", body="", state="open", labels=None,
|
||||||
assignees=None, milestone="", depends=None, origin=LOCAL,
|
assignees=None, milestone="", depends=None,
|
||||||
extra=None):
|
origin=LOCAL, extra=None):
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.body = body
|
self.body = body
|
||||||
@@ -262,8 +263,11 @@ class Issue(object):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_local(self):
|
def is_local(self):
|
||||||
"""True while this issue exists nowhere but here — a durable state,
|
"""True while this issue exists nowhere but here.
|
||||||
not a pending one."""
|
|
||||||
|
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
|
return self.origin == LOCAL
|
||||||
|
|
||||||
# -- taxonomy views ----------------------------------------------------
|
# -- taxonomy views ----------------------------------------------------
|
||||||
@@ -617,10 +621,48 @@ def path_of(root, id):
|
|||||||
|
|
||||||
|
|
||||||
def all_ids(root):
|
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):
|
if not os.path.isdir(root):
|
||||||
return []
|
return []
|
||||||
return sorted(f[:-3] for f in os.listdir(root)
|
return sorted(f[:-3] for f in os.listdir(root)
|
||||||
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-")))
|
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
|
||||||
|
and "." not in f[:-3])
|
||||||
|
|
||||||
|
|
||||||
|
def slug_files(root, id):
|
||||||
|
"""Every file the store holds under one slug — the issue and its sidecars.
|
||||||
|
|
||||||
|
`<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
||||||
|
companion another layer parked there (`<id>.comments.md` is the one that
|
||||||
|
exists today). `all_ids` already refuses to read those as issues because a
|
||||||
|
slug has no dot in it; this is the same rule read the other way round.
|
||||||
|
|
||||||
|
Which is how the domain can remove an issue *completely* without learning
|
||||||
|
what any of those companions are: it does not need to know that a comment
|
||||||
|
thread exists to know that a file named after this issue belongs to it and
|
||||||
|
goes when it goes. The issue's own file comes first — it is the headline of
|
||||||
|
any receipt printed from this list.
|
||||||
|
|
||||||
|
A missing store is an empty list, not an error: nothing is there to remove.
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return []
|
||||||
|
own, sidecars = [], []
|
||||||
|
for name in sorted(os.listdir(root)):
|
||||||
|
if not name.startswith("%s." % id):
|
||||||
|
continue
|
||||||
|
p = os.path.join(root, name)
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
continue
|
||||||
|
(own if name == "%s.md" % id else sidecars).append(p)
|
||||||
|
return own + sidecars
|
||||||
|
|
||||||
|
|
||||||
def load(root, id):
|
def load(root, id):
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
issue_evict.py — closed issues leave the store. Offline.
|
||||||
|
|
||||||
|
issue_evict.py every closed issue that is not origin: local
|
||||||
|
issue_evict.py old-thing … only these
|
||||||
|
issue_evict.py --dry-run print what would go; touch nothing
|
||||||
|
|
||||||
|
The store is a working set, not an archive. A closed issue is not a unit of
|
||||||
|
work any more, and `pull.py` has kept new ones out of filter mode for a while —
|
||||||
|
but the files already on disk were nobody's job, so the only way to remove one
|
||||||
|
was `rm` past every script, followed by rebuilding `INDEX.md` by hand. This is
|
||||||
|
that job.
|
||||||
|
|
||||||
|
WHAT IS EVICTED, and it is two conditions, both read off the file:
|
||||||
|
|
||||||
|
state: closed the work is done
|
||||||
|
origin: <tracker> the work is somewhere else too
|
||||||
|
|
||||||
|
TWO CONDITIONS, AND THE SECOND ONE IS THE WHOLE SAFETY ARGUMENT. `origin:
|
||||||
|
local` means this file IS the issue — there is no other copy and deleting it
|
||||||
|
deletes the work. It is therefore never evicted, in any state, not even when
|
||||||
|
named explicitly on the command line: a closed local issue is reported and
|
||||||
|
kept. The only files that go are ones whose own metadata says the work can be
|
||||||
|
fetched back (`pull.py <n>`), which is the same trade `push.py` makes when it
|
||||||
|
drops a file the tracker has just confirmed.
|
||||||
|
|
||||||
|
That parallel is exact except for where the confirmation comes from. Push has
|
||||||
|
to ask Gitea, because it is Gitea that just changed. Eviction asks the file,
|
||||||
|
because `state:` and `origin:` are domain fields and the answer is already in
|
||||||
|
the store — which is why this command lives in the domain layer and needs no
|
||||||
|
network, no login, and no `tea`. See `skills/sync/scripts/evict.py` for the
|
||||||
|
variant that refreshes `state:` from the tracker first; it makes the deletion
|
||||||
|
decision by calling `run()` below, so there is exactly one implementation of
|
||||||
|
"what may be evicted" and it is this one.
|
||||||
|
|
||||||
|
NOT A ONE-OFF MIGRATION. `pull.py <n>` fetches an issue in any state — a number
|
||||||
|
is an address, not a query — so a closed issue pulled after an eviction lands on
|
||||||
|
disk again. That is the tracker being asked a direct question, not a regression,
|
||||||
|
and the answer is to evict again when you are done with it.
|
||||||
|
|
||||||
|
`.remote.json` is deliberately NOT pruned. It is the local number -> slug
|
||||||
|
ledger, its entries outlive the files they name (that is what makes `pull.py
|
||||||
|
<n>` land on the same slug after a push deleted the file), and an evicted issue
|
||||||
|
is in exactly that state. `INDEX.md` is rebuilt, because it *is* a view of the
|
||||||
|
directory.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
# Why an issue was kept, in the receipt. `LOCAL_REASON` is the one that matters:
|
||||||
|
# it is printed whether or not the issue was named, because "this closed thing
|
||||||
|
# is still here" needs an answer every time.
|
||||||
|
LOCAL_REASON = "origin: %s — this file IS the issue" % issue.LOCAL
|
||||||
|
|
||||||
|
|
||||||
|
def classify(issues, ids=None):
|
||||||
|
"""Split the store into (evict, protected, still_open).
|
||||||
|
|
||||||
|
Pure — it reads the loaded issues and decides; nothing here touches disk.
|
||||||
|
|
||||||
|
evict closed, and lives in a tracker too: safe to remove
|
||||||
|
protected closed, but `origin: local`: the only copy of the work
|
||||||
|
still_open not closed
|
||||||
|
|
||||||
|
`ids` restricts the question to those issues; without it the whole store is
|
||||||
|
considered. A protected issue is returned as such even when it was named
|
||||||
|
explicitly — naming a file does not make deleting it safe.
|
||||||
|
"""
|
||||||
|
chosen = list(ids) if ids else sorted(issues)
|
||||||
|
evict, protected, still_open = [], [], []
|
||||||
|
for id in chosen:
|
||||||
|
iss = issues[id]
|
||||||
|
if iss.state != CLOSED:
|
||||||
|
still_open.append(id)
|
||||||
|
elif iss.is_local:
|
||||||
|
protected.append(id)
|
||||||
|
else:
|
||||||
|
evict.append(id)
|
||||||
|
return evict, protected, still_open
|
||||||
|
|
||||||
|
|
||||||
|
def remove(root, id):
|
||||||
|
"""Delete everything the store holds under one slug; return the paths.
|
||||||
|
|
||||||
|
Deliberately dumb, and for the same reason `push.drop_local` is: it takes an
|
||||||
|
id, not a decision. Whether an issue may go is settled by `classify` before
|
||||||
|
this is reached, so the dangerous half of the operation has no branches in
|
||||||
|
it at all. There is exactly one call site.
|
||||||
|
"""
|
||||||
|
gone = []
|
||||||
|
for p in issue.slug_files(root, id):
|
||||||
|
os.remove(p)
|
||||||
|
gone.append(p)
|
||||||
|
return gone
|
||||||
|
|
||||||
|
|
||||||
|
def run(root, issues, ids=None, dry_run=False, out=None):
|
||||||
|
"""Classify, report, remove, rebuild the index. Returns (gone, kept).
|
||||||
|
|
||||||
|
The one implementation of eviction, called both by `main` below and by the
|
||||||
|
sync layer's `evict.py` — which does nothing to this decision except hand
|
||||||
|
over issues whose `state:` it has just refreshed from the tracker.
|
||||||
|
|
||||||
|
`gone` is {id: [paths]} and is empty on a dry run; `kept` is
|
||||||
|
[(id, why)] for everything considered and not removed.
|
||||||
|
"""
|
||||||
|
out = out or sys.stdout
|
||||||
|
evict, protected, still_open = classify(issues, ids)
|
||||||
|
|
||||||
|
gone, kept = {}, []
|
||||||
|
for id in evict:
|
||||||
|
paths = issue.slug_files(root, id) if dry_run else remove(root, id)
|
||||||
|
if not dry_run:
|
||||||
|
gone[id] = paths
|
||||||
|
out.write("%-11s %s\n" % ("would evict" if dry_run else "evicted", id))
|
||||||
|
for p in paths:
|
||||||
|
out.write(" %s\n" % p)
|
||||||
|
for id in protected:
|
||||||
|
kept.append((id, LOCAL_REASON))
|
||||||
|
out.write("%-11s %s closed, %s\n" % ("kept", id, LOCAL_REASON))
|
||||||
|
# An open issue is the normal case and says nothing worth a line — unless
|
||||||
|
# the operator named it, in which case they are owed the reason.
|
||||||
|
for id in still_open:
|
||||||
|
kept.append((id, "state: %s" % issues[id].state))
|
||||||
|
if ids:
|
||||||
|
out.write("%-11s %s state: %s\n" % ("kept", id, issues[id].state))
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
out.write("%d issue(s) would be evicted, %d kept — nothing was touched\n"
|
||||||
|
% (len(evict), len(kept)))
|
||||||
|
return gone, kept
|
||||||
|
|
||||||
|
out.write("%d issue(s) evicted, %d kept\n" % (len(gone), len(kept)))
|
||||||
|
# Only when something actually went: the index is a view of the directory,
|
||||||
|
# and rewriting it after a run that changed nothing is a write nobody asked
|
||||||
|
# for.
|
||||||
|
if gone:
|
||||||
|
path, n = issue_index.build(root)
|
||||||
|
out.write("index: %s — %d issue(s)\n" % (path, n))
|
||||||
|
return gone, kept
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Evict closed issues from the local store (offline)")
|
||||||
|
ap.add_argument("ids", nargs="*",
|
||||||
|
help="issue ids (default: every closed issue in the store)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true",
|
||||||
|
help="print what would be removed; touch nothing")
|
||||||
|
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||||
|
help="store root (default: <repo>/tmp/issues)")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
root = args.out
|
||||||
|
if not issue.store_exists(root):
|
||||||
|
sys.exit("issue_evict.py: store %s does not exist — nothing to evict" % root)
|
||||||
|
|
||||||
|
issues = issue.load_all(root)
|
||||||
|
missing = [i for i in args.ids if i not in issues]
|
||||||
|
if missing:
|
||||||
|
sys.exit("issue_evict.py: no such issue(s) in the store: %s"
|
||||||
|
% ", ".join(missing))
|
||||||
|
|
||||||
|
run(root, issues, args.ids, args.dry_run)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
issue_new.py — create an issue in the local store. Offline, always.
|
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
|
The issue is real the moment this writes the file. Nothing is pending, nothing
|
||||||
is a draft awaiting a tracker: `origin: local` is a durable state, and pushing
|
is a draft awaiting a tracker: `origin: local` is a complete state and pushing
|
||||||
it to Gitea later (see /tea:sync) is optional and additive.
|
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" \
|
issue_new.py --type task --title "Wire sqlc into the appclick repo layer" \
|
||||||
--label tech/sql --label comp/appclick
|
--label tech/sql --label comp/appclick
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
+272
-28
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: sync
|
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.
|
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen 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
|
# /tea:sync — the bridge between the local store and Gitea
|
||||||
@@ -32,15 +32,22 @@ index.
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
|
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
|
operator's pin from `.claude/settings.local.json` through
|
||||||
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
|
`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
|
||||||
|
not merely the same file, so a directory where `tea` works is a directory where
|
||||||
|
these work. That includes a **git worktree**, whose untracked pin sits in the
|
||||||
|
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
|
||||||
|
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
|
||||||
|
`/tea:auth`.
|
||||||
|
|
||||||
| Script | What it does |
|
| Script | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing |
|
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
|
||||||
| `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 |
|
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
|
||||||
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success |
|
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
|
||||||
|
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
|
||||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||||
|
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
|
||||||
| `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` |
|
| `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 |
|
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
|
||||||
|
|
||||||
@@ -56,8 +63,8 @@ directory. Pass `--out` to override; a relative one stays relative to cwd. Only
|
|||||||
|
|
||||||
## Identity mapping
|
## Identity mapping
|
||||||
|
|
||||||
The local id is a slug; Gitea's is a number. The pair is recorded in the issue
|
The local id is a slug; Gitea's is a number. While a working copy exists, the
|
||||||
file itself:
|
pair is in the file:
|
||||||
|
|
||||||
```
|
```
|
||||||
origin: gitea
|
origin: gitea
|
||||||
@@ -66,12 +73,20 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
|
|||||||
synced: 2026-08-09T18:40:00Z
|
synced: 2026-08-09T18:40:00Z
|
||||||
```
|
```
|
||||||
|
|
||||||
`tmp/issues/.remote.json` indexes those fields for fast lookup. It is a cache
|
But the file is deleted on push, so the pair also lives in two places that
|
||||||
over the files, not a second source of truth — delete it and the next command
|
outlast it: `tmp/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
|
||||||
rebuilds it.
|
marker in the issue body on the Gitea side. See [How the slug comes
|
||||||
|
back](#how-the-slug-comes-back).
|
||||||
|
|
||||||
A retitled issue keeps its slug: the map is keyed by number, so a pull updates
|
`.remote.json` used to be described as an index over the files. It is not one
|
||||||
the existing file instead of creating a second 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
|
## Pulling
|
||||||
|
|
||||||
@@ -80,7 +95,7 @@ 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 --milestone 6 # id or title
|
||||||
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
|
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 -q sqlc --limit 20
|
||||||
python3 <skill-base-dir>/scripts/pull.py 40 --deps # follow dependencies
|
python3 <skill-base-dir>/scripts/pull.py 40 --no-deps # this issue only
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not loop over numbers to pull a group — pass the filter. The list endpoint
|
Do not loop over numbers to pull a group — pass the filter. The list endpoint
|
||||||
@@ -88,6 +103,12 @@ carries the issue bodies, so a milestone costs **one request per 50 issues**,
|
|||||||
not one per issue. Filters AND together; `--state` defaults to `open`;
|
not one per issue. Filters AND together; `--state` defaults to `open`;
|
||||||
`--limit` to 100. Keys and filters are mutually exclusive.
|
`--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
|
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
||||||
local edits are lost, with one exception: [checkbox
|
local edits are lost, with one exception: [checkbox
|
||||||
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
|
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
|
||||||
@@ -100,13 +121,55 @@ 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
|
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.
|
closed issue as always, because an address is not a bulk read.
|
||||||
|
|
||||||
|
**`--limit N` bounds the write, not the selection.** N is how many issues this
|
||||||
|
run leaves in the store — written, or left in place by `--cached`. Closed ones
|
||||||
|
that were enumerated and thrown away do not spend it, so `--limit 20` over a
|
||||||
|
milestone whose first 30 issues are closed still writes 20, as long as 20 open
|
||||||
|
ones are there to write. Pagination follows the budget rather than the other way
|
||||||
|
round:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| budget full | the next page is never requested |
|
||||||
|
| pages run out | fewer than N, and that is the honest answer |
|
||||||
|
| filter matches almost only closed issues | at most 4× the pages N would need if nothing were dropped, then a warning on stderr and a short answer — raising `--limit` raises that ceiling too |
|
||||||
|
| dependencies | outside the count: a blocker is followed because a stored issue named it, not because the filter selected it — so `--limit 20` can leave more than 20 files behind |
|
||||||
|
|
||||||
|
`remote.py --limit` means something else, deliberately: it caps the **listing**,
|
||||||
|
closed issues included. It writes nothing, so there is no write for a limit to
|
||||||
|
bound — enumeration is its whole job.
|
||||||
|
|
||||||
**Comments come with every pull** — there is no flag. An issue that has a
|
**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
|
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
|
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
|
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
|
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
|
been emptied is deleted. `--cached` skips the thread along with the body, so a
|
||||||
skipped issue makes no request at all.
|
skipped issue makes one request for its links and no other.
|
||||||
|
|
||||||
|
**Dependencies come with every pull too, and this one costs.** A pull answers
|
||||||
|
with the unit of work — the issue and what blocks it — so `depends:` is filled
|
||||||
|
from Gitea's native graph and every blocker is pulled as well, recursively, down
|
||||||
|
to `--depth` (default 3). It has to come from the native graph: the body's
|
||||||
|
`## Depends on` section holds slugs, never `#N`, so there is no edge to recover
|
||||||
|
from the text. `--no-deps` turns off both halves. `--deps` is still accepted and
|
||||||
|
does nothing — it names the default.
|
||||||
|
|
||||||
|
| | requests |
|
||||||
|
|---|---|
|
||||||
|
| every issue that lands in the store | **+1** — `GET …/issues/{n}/dependencies`, fetched once and used twice (fills `depends:`, steers the walk) |
|
||||||
|
| every blocker the selection did not already carry | **+1** to fetch it, then its own links, until `--depth` |
|
||||||
|
| a closed issue filter mode drops | 0 — nothing was stored, so there is no unit of work to complete |
|
||||||
|
| `--milestone X` over 50 open issues | 1 list request + 50, plus a pair per outside blocker — it used to be 1 |
|
||||||
|
| the same with `--no-deps` | 1 |
|
||||||
|
|
||||||
|
**A blocker the filter did not select still lands in the store, deliberately.**
|
||||||
|
`--milestone X` can leave an issue from milestone Y on disk; `--label` can leave
|
||||||
|
an unlabelled one. It is there because a stored issue names it, not because it
|
||||||
|
matched. The exception is a closed blocker: closed is not a unit of work, filter
|
||||||
|
mode drops it like any other closed issue, and the `depends:` edge to it goes
|
||||||
|
with it — nothing is left pointing at a file that is not there. Key mode
|
||||||
|
(`pull.py 42`) has no such rule and stores it.
|
||||||
|
|
||||||
Two traps this handles for you:
|
Two traps this handles for you:
|
||||||
|
|
||||||
@@ -163,9 +226,64 @@ python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
|
|||||||
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
|
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pushing is additive: the local file is never deleted.** It gains `gitea:`,
|
**A successful push DELETES the local file** — `tmp/issues/<id>.md` and
|
||||||
`url:`, `synced:`, and `origin:` flips to `gitea`. One issue, visible in two
|
`<id>.comments.md` — and prints the number and URL the issue now lives at:
|
||||||
places — not two kinds of file.
|
|
||||||
|
```
|
||||||
|
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, every `pull.py` 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/*`,
|
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
|
||||||
at most one `severity/*`, English title with no type prefix, `## Summary` /
|
at most one `severity/*`, English title with no type prefix, `## Summary` /
|
||||||
@@ -184,7 +302,7 @@ The two directions are symmetric, and they use the same endpoint:
|
|||||||
| | direction | endpoint |
|
| | direction | endpoint |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
|
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
|
||||||
| `pull.py --deps` | native links → `depends:` | `GET …/issues/{n}/dependencies` |
|
| `pull.py` (default; `--no-deps` off) | native links → `depends:` | `GET …/issues/{n}/dependencies` |
|
||||||
|
|
||||||
The POST body is Gitea's `IssueMeta` — `{"index", "owner", "repo"}` naming the
|
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
|
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
|
||||||
@@ -229,28 +347,140 @@ decision, not a migration. A color or `exclusive` that drifted is printed, and
|
|||||||
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
|
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
|
||||||
`comp/*` are open-ended by design and stay push-created.
|
`comp/*` are open-ended by design and stay push-created.
|
||||||
|
|
||||||
|
Labels belong to the repository, not to any issue, so this one runs on a
|
||||||
|
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
|
||||||
|
and nothing creates it. The request bodies go to `tmp/payload/` (below).
|
||||||
|
|
||||||
A milestone must already exist in the repo — push attaches, it does not create.
|
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
|
`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`)
|
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
|
and sends it up as `ref`; a value already there is never
|
||||||
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
|
overwritten, neither on create nor on `--update`. Nothing is written back to
|
||||||
|
the issue file — there is no file left to write to, because a successful push
|
||||||
|
deletes it. The branch comes back on disk with the next `pull.py <n>`, from
|
||||||
|
the tracker. On a detached HEAD or outside
|
||||||
a git repo no `ref` is sent and a warning names the issues that went up without
|
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
|
one. Reading the branch is the only thing these scripts ask git for — they
|
||||||
never check out, create, or write anything.
|
never check out, create, or write anything.
|
||||||
|
|
||||||
|
The branch comes from the **current directory**, so run `push.py` from the tree
|
||||||
|
the work is on. In a git worktree that is the worktree, and it is now also
|
||||||
|
where the pin resolves from: the old workaround for the pin — run the scripts
|
||||||
|
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
|
||||||
|
is the one thing `branch:` exists to record.
|
||||||
|
|
||||||
|
## Closing and reopening
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-base-dir>/scripts/close.py wire-sqlc-appclick # by slug
|
||||||
|
python3 <skill-base-dir>/scripts/close.py 42 '#43' # by number
|
||||||
|
python3 <skill-base-dir>/scripts/close.py --reopen 42
|
||||||
|
python3 <skill-base-dir>/scripts/close.py --dry-run 42 43 # no request at all
|
||||||
|
```
|
||||||
|
|
||||||
|
`close.py` is the only supported way to move `state:`. Never hand-roll
|
||||||
|
`tea api -X PATCH -d '{"state":"closed"}' repos/OWNER/REPO/issues/N`: it spells
|
||||||
|
out the owner, the repo and the request body — the three things this layer
|
||||||
|
exists to hide — and it needs a `Bash(tea api *)` permission that also covers
|
||||||
|
`-X DELETE` on the repository.
|
||||||
|
|
||||||
|
**State only.** The payload is `{"state": …}` and nothing else — no title, no
|
||||||
|
body, no labels, no milestone. Closing is not an edit; editing is `pull.py` →
|
||||||
|
change → `push.py --update`.
|
||||||
|
|
||||||
|
**Explicit ids only.** There is no `--milestone` and no `--label`: which issues
|
||||||
|
are finished is a judgement about content, and this script only carries one
|
||||||
|
out, one named id at a time. Deleting an issue is out of scope too — Gitea can,
|
||||||
|
and it is not an operation of this workflow.
|
||||||
|
|
||||||
|
What may be named, and what happens to the local copy:
|
||||||
|
|
||||||
|
| named | resolved through | local file |
|
||||||
|
|---|---|---|
|
||||||
|
| a slug with a file on disk | its `gitea:` field | `state:` rewritten, `synced:` refreshed |
|
||||||
|
| a slug whose file push dropped | `.remote.json` | none to write — say so and move on |
|
||||||
|
| `42`, `#42`, `owner/repo#42`, a URL | the key itself; the ledger supplies the slug | rewritten when a file of that slug is there |
|
||||||
|
| a slug with `origin: local` | — | **refused**: it is not in the tracker, and the error names the id |
|
||||||
|
|
||||||
|
The local file is written only after the tracker has confirmed *this* write: an
|
||||||
|
object carrying the very number that was PATCHed, in the state that was asked
|
||||||
|
for. A non-2xx, a `tea` that would not run, an answer for another issue, a 200
|
||||||
|
that still says `open` — the run stops and the file is byte for byte what it
|
||||||
|
was. `--dry-run` prints the same lines and makes no request at all, so it needs
|
||||||
|
no pinned login.
|
||||||
|
|
||||||
|
Gitea refuses to close an issue that its own dependency graph still blocks. The
|
||||||
|
refusal arrives as a non-2xx with the tracker's own words: close the blockers
|
||||||
|
first, or unlink them in the web UI.
|
||||||
|
|
||||||
|
The index is rebuilt when at least one local file changed, so `INDEX.md` never
|
||||||
|
outlives the state it reports. Nothing is deleted here — unlike a push, a close
|
||||||
|
leaves the working copy where it is.
|
||||||
|
|
||||||
|
## Evicting what the tracker says is closed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-base-dir>/scripts/evict.py --dry-run # ask, report, change nothing
|
||||||
|
python3 <skill-base-dir>/scripts/evict.py # and remove them
|
||||||
|
python3 <skill-base-dir>/scripts/evict.py old-thing # just this one
|
||||||
|
```
|
||||||
|
|
||||||
|
Eviction itself belongs to `/tea:issue` (`issue_evict.py`) and is offline: the
|
||||||
|
decision is `state: closed` plus an `origin:` that names a tracker, both read
|
||||||
|
off the file. This script adds one thing in front of it — a `state:` that is not
|
||||||
|
stale — and then calls that same decision. There is one implementation of "what
|
||||||
|
may be evicted" and it is in the domain.
|
||||||
|
|
||||||
|
Why it exists: a local `state:` is only as fresh as the last pull, so an issue
|
||||||
|
closed in the web UI still reads `open` here and the offline command correctly
|
||||||
|
leaves it alone. The workaround was `pull.py 11 12 13 14 15` — which writes the
|
||||||
|
five closed files back to disk before anything can remove them.
|
||||||
|
|
||||||
|
Order of operations, and it is the safety argument:
|
||||||
|
|
||||||
|
1. every candidate's state is fetched — **all** of them, before anything is
|
||||||
|
removed;
|
||||||
|
2. each answer must be an object carrying the number that was asked about and a
|
||||||
|
state the domain recognizes (`evict.confirmed_state`, the counterpart of
|
||||||
|
`push.confirmed_number`);
|
||||||
|
3. only then does the eviction run.
|
||||||
|
|
||||||
|
**A failed call evicts nothing** — not even the candidates whose answers had
|
||||||
|
already arrived, and no refreshed `state:` is written back either. Stricter than
|
||||||
|
push, which deletes as it goes, and free: evictions have no order between them,
|
||||||
|
so there is no reason to start before every answer is in.
|
||||||
|
|
||||||
|
- A **candidate** is an issue carrying a `gitea:` handle. `origin: local` has
|
||||||
|
none, is never asked about, and is never removed. An `origin: gitea` issue
|
||||||
|
whose handle is missing or unparseable cannot be verified — it is reported on
|
||||||
|
stderr and kept.
|
||||||
|
- No `--repo`: the repo comes from each issue's own handle, so a store holding
|
||||||
|
issues from two repos is checked against both.
|
||||||
|
- One GET per candidate. The store is a working set that push keeps small, and a
|
||||||
|
wrong answer here deletes a file — so each issue is asked about by its own
|
||||||
|
address rather than inferred from a list a `--limit` could have truncated.
|
||||||
|
- A state that disagrees with the file is written back, so the store stops lying
|
||||||
|
about the issues that stay too. `--dry-run` makes no writes at all.
|
||||||
|
- `.remote.json` is not pruned; see [How the slug comes
|
||||||
|
back](#how-the-slug-comes-back) — an evicted issue is exactly as findable as a
|
||||||
|
pushed one.
|
||||||
|
- **`pull.py <n>` still fetches a closed issue.** A number is an address, not a
|
||||||
|
query. A closed issue pulled after an eviction is back on disk, and that is
|
||||||
|
the tracker answering the question it was asked, not a regression.
|
||||||
|
|
||||||
## What crosses the boundary, and what does not
|
## What crosses the boundary, and what does not
|
||||||
|
|
||||||
| domain | Gitea | note |
|
| domain | Gitea | note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `id` (slug) | — | local only; the tracker never sees it |
|
| `id` (slug) | `<!-- tea:id … -->` | first line of the tracker-side body; stripped out of the local copy |
|
||||||
| title | `title` | verbatim, both directions |
|
| title | `title` | verbatim, both directions |
|
||||||
| body | `body` | verbatim up; verbatim down except checkbox state, which is unioned |
|
| body | `body` | verbatim up except the marker; verbatim down except the marker and checkbox state, which is unioned |
|
||||||
| `state` | `state` | same vocabulary |
|
| `state` | `state` | same vocabulary |
|
||||||
| `labels` | `labels[]` | names both ways; ids only on write |
|
| `labels` | `labels[]` | names both ways; ids only on write |
|
||||||
| `assignees` | `assignees[]` | logins |
|
| `assignees` | `assignees[]` | logins |
|
||||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
| `milestone` | `milestone.title` | resolved to an id on write |
|
||||||
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, `pull --deps` reads them |
|
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, every pull reads them (`--no-deps` opts out) |
|
||||||
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
||||||
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
||||||
|
|
||||||
@@ -266,10 +496,17 @@ Comments are **pull-only** in the store: `<id>.comments.md` is written by
|
|||||||
|
|
||||||
## Drift
|
## Drift
|
||||||
|
|
||||||
There is none tracked. The store is not a mirror: nothing watches Gitea,
|
There is none tracked, and since push started deleting what it sends there is
|
||||||
nothing reconciles, nothing warns that a synced issue changed upstream.
|
very little left to track. A published issue has **one** copy — Gitea's —
|
||||||
`synced:` tells you how old your copy is; `remote-updated:` what the server
|
except while somebody is working on it, and that window closes at the next
|
||||||
said at that moment. Re-pull when it matters.
|
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
|
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
|
the two bodies in front of it — there is no base version, no history, and no
|
||||||
@@ -278,6 +515,13 @@ precisely so the mechanism this section rules out is not needed.
|
|||||||
|
|
||||||
## Rich payloads for everything else
|
## Rich payloads for everything else
|
||||||
|
|
||||||
|
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
|
||||||
|
first and passed as `-d @file`, then kept for a retry or a look at what actually
|
||||||
|
went up. One gitignored directory for all of them, chosen by the transport and
|
||||||
|
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
|
||||||
|
and it is never `tmp/issues/` — a command that touches no issue must not leave
|
||||||
|
an issue store behind.
|
||||||
|
|
||||||
Comments and issues are wrapped by the scripts above. For **other** entities
|
Comments and issues are wrapped by the scripts above. For **other** entities
|
||||||
(pulls, releases, PATCHing something these scripts do not cover), entity
|
(pulls, releases, PATCHing something these scripts do not cover), entity
|
||||||
subcommands like `tea pulls create` hang on a large or formatted body — an
|
subcommands like `tea pulls create` hang on a large or formatted body — an
|
||||||
|
|||||||
+205
-50
@@ -7,14 +7,26 @@ 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
|
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
|
||||||
lives one layer further out in skills/issue/scripts/issue.py.
|
lives one layer further out in skills/issue/scripts/issue.py.
|
||||||
|
|
||||||
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up
|
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
|
||||||
from CWD — the same file /tea:auth writes and the tea-guard hook reads. No
|
Where that file is searched for is NOT written here — skills/auth/scripts/pin.py
|
||||||
|
owns the search order, and the tea-guard hook imports the same module, so `tea`
|
||||||
|
and the scripts can never disagree about which login a directory runs under. No
|
||||||
script here accepts a login argument: the operator's pin is the only identity
|
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.
|
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
|
Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with
|
||||||
a local slug. It is transport bookkeeping, not domain data — the domain never
|
a local slug, and the paths of the store-side files this layer writes. All of
|
||||||
reads it, and losing it costs a re-pull, not information.
|
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`.
|
||||||
|
|
||||||
|
Request bodies go to tmp/payload/, which is this module's own scratchpad and
|
||||||
|
NOT a store: nothing in it is anybody's only copy, and writing one must never
|
||||||
|
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
|
||||||
|
touches no issue at all — it used to leave a store behind anyway, because the
|
||||||
|
request file had nowhere else to live. One directory, every caller, resolved
|
||||||
|
from this file the way the two domains resolve theirs.
|
||||||
"""
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
@@ -24,9 +36,32 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
PAYLOAD_DIR = ".payload"
|
|
||||||
REMOTE_MAP = ".remote.json"
|
REMOTE_MAP = ".remote.json"
|
||||||
|
|
||||||
|
# How far past the ideal page count a `keep`-bounded listing may scan before it
|
||||||
|
# gives up (see list_issues). The ideal is what `limit` would need if every
|
||||||
|
# payload counted; the slack pays for the ones that do not. It is a bound on
|
||||||
|
# requests, deliberately small: "fetch until N are kept" without one is "fetch
|
||||||
|
# the whole tracker" on any repo whose filter matches mostly closed issues.
|
||||||
|
PAGE_SLACK = 4
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# where request bodies land
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Anchored on THIS FILE, like issue.store_root, so every caller — sync,
|
||||||
|
# whatever comes next — writes to one directory whatever it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
|
||||||
|
# inside somebody's store, because a scratchpad that looks like store contents
|
||||||
|
# is how this went wrong the first time. `tmp/` is already gitignored.
|
||||||
|
|
||||||
|
PAYLOAD_PARTS = ("tmp", "payload")
|
||||||
|
|
||||||
|
# `.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 die(msg, code=1):
|
def die(msg, code=1):
|
||||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||||
@@ -41,32 +76,64 @@ def now_iso():
|
|||||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
def repo_root(start):
|
||||||
# login
|
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
|
||||||
# --------------------------------------------------------------------------
|
d = os.path.abspath(start)
|
||||||
|
|
||||||
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:
|
while True:
|
||||||
p = os.path.join(d, ".claude", "settings.local.json")
|
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
|
||||||
if os.path.isfile(p):
|
return d
|
||||||
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)
|
parent = os.path.dirname(d)
|
||||||
if parent == d:
|
if parent == d:
|
||||||
return None
|
return None
|
||||||
d = parent
|
d = parent
|
||||||
|
|
||||||
|
|
||||||
|
def payload_root(start=None):
|
||||||
|
"""Absolute path of the request-body scratchpad.
|
||||||
|
|
||||||
|
`start` overrides the anchor so the resolution can be exercised against a
|
||||||
|
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
|
||||||
|
location stands — made absolute so an error can name the directory it
|
||||||
|
really wrote to."""
|
||||||
|
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||||
|
root = repo_root(anchor)
|
||||||
|
if root:
|
||||||
|
return os.path.join(root, *PAYLOAD_PARTS)
|
||||||
|
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
|
||||||
|
|
||||||
|
|
||||||
|
PAYLOAD_ROOT = payload_root()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# login
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the
|
||||||
|
# single written copy of the search order, and the tea-guard hook calls the
|
||||||
|
# same function. When the two had a copy each, a git worktree got a hook that
|
||||||
|
# resolved the pin and a transport that did not — in the same directory.
|
||||||
|
#
|
||||||
|
# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those
|
||||||
|
# are anchored on their own file, this is not, and both are right. Where an
|
||||||
|
# installation keeps its files is a fact about the installation; whose login a
|
||||||
|
# project runs under is a fact about the project, and a plugin installed
|
||||||
|
# outside any repository must not answer it from its own directory. See the
|
||||||
|
# module docstring in pin.py.
|
||||||
|
|
||||||
|
_AUTH_SCRIPTS = os.path.abspath(
|
||||||
|
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
|
||||||
|
if _AUTH_SCRIPTS not in sys.path:
|
||||||
|
sys.path.append(_AUTH_SCRIPTS)
|
||||||
|
import pin # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def require_login():
|
def require_login():
|
||||||
login = find_pin(os.getcwd())
|
"""The operator's pinned login, or exit pointing at /tea:auth.
|
||||||
|
|
||||||
|
No pin found is reported as exactly that. It stays a truthful message: the
|
||||||
|
fix for "the pin is somewhere this search does not reach" belongs in
|
||||||
|
pin.py, never in a hint here that sends the operator to pin it twice."""
|
||||||
|
login, _ = pin.find_pin()
|
||||||
if not login:
|
if not login:
|
||||||
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
||||||
return login
|
return login
|
||||||
@@ -77,19 +144,21 @@ def require_login():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
||||||
out_root=None, allow_fail=False):
|
allow_fail=False):
|
||||||
"""Call `tea api`; return parsed JSON (None on an empty body).
|
"""Call `tea api`; return parsed JSON (None on an empty body).
|
||||||
|
|
||||||
payload (a dict) is written to <out_root>/.payload/<name>.json and passed
|
payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
|
||||||
as -d @file — the file survives the call for retries and debugging.
|
-d @file — the file survives the call for retries and debugging. Where
|
||||||
allow_fail returns None instead of exiting when the call fails."""
|
that is, is not the caller's business and never was: the directory is
|
||||||
|
this layer's scratchpad, and the one time it was a caller's decision it
|
||||||
|
got pointed at the issue store. allow_fail returns None instead of
|
||||||
|
exiting when the call fails."""
|
||||||
cmd = ["tea", "api", "--login", login]
|
cmd = ["tea", "api", "--login", login]
|
||||||
if method != "GET":
|
if method != "GET":
|
||||||
cmd += ["-X", method]
|
cmd += ["-X", method]
|
||||||
if payload is not None:
|
if payload is not None:
|
||||||
pdir = os.path.join(out_root or ".", PAYLOAD_DIR)
|
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
|
||||||
os.makedirs(pdir, exist_ok=True)
|
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
|
||||||
path = os.path.join(pdir, "%s.json" % (payload_name or "request"))
|
|
||||||
with open(path, "w") as f:
|
with open(path, "w") as f:
|
||||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
cmd += ["-d", "@" + path]
|
cmd += ["-d", "@" + path]
|
||||||
@@ -111,17 +180,28 @@ def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
|||||||
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
|
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
|
||||||
|
|
||||||
|
|
||||||
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
|
def pages(login, endpoint, limit=50, max_pages=40, **kw):
|
||||||
"""GET a list endpoint page by page; return the concatenated list."""
|
"""GET a list endpoint page by page, yielding each page as it arrives.
|
||||||
|
|
||||||
|
A generator, because a caller whose budget is spent on what it *keeps*
|
||||||
|
cannot be served by a function that fetches everything first: the page after
|
||||||
|
the one that completed the budget must never be requested. Stop consuming
|
||||||
|
and no further request is made."""
|
||||||
sep = "&" if "?" in endpoint else "?"
|
sep = "&" if "?" in endpoint else "?"
|
||||||
out = []
|
|
||||||
for page in range(1, max_pages + 1):
|
for page in range(1, max_pages + 1):
|
||||||
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
|
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
|
||||||
if not isinstance(batch, list) or not batch:
|
if not isinstance(batch, list) or not batch:
|
||||||
break
|
return
|
||||||
out.extend(batch)
|
yield batch
|
||||||
if len(batch) < limit:
|
if len(batch) < limit:
|
||||||
break
|
return # a short page is the last one
|
||||||
|
|
||||||
|
|
||||||
|
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
|
||||||
|
"""GET a list endpoint page by page; return the concatenated list."""
|
||||||
|
out = []
|
||||||
|
for batch in pages(login, endpoint, limit=limit, max_pages=max_pages, **kw):
|
||||||
|
out.extend(batch)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -184,11 +264,31 @@ def matches(payload, milestone_id=None, labels=()):
|
|||||||
|
|
||||||
|
|
||||||
def list_issues(login, base, state="open", labels=(), query=None,
|
def list_issues(login, base, state="open", labels=(), query=None,
|
||||||
milestone=None, limit=100):
|
milestone=None, limit=100, keep=None):
|
||||||
"""Filtered issue payloads. Returns (payloads, milestone_title).
|
"""Filtered issue payloads. Returns (payloads, milestone_title).
|
||||||
|
|
||||||
One request per page, and the payload already carries the issue bodies — a
|
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."""
|
whole milestone costs one call per 50 issues, not one per issue.
|
||||||
|
|
||||||
|
`limit` counts the payloads the CALLER cares about, not the ones the server
|
||||||
|
returned. Without `keep` those are the same thing and this behaves as it
|
||||||
|
always did. With it, `keep(payload)` says whether a payload counts, pages
|
||||||
|
keep coming until `limit` of them have, and the returned list carries the
|
||||||
|
ones that did not count too — they were enumerated, and a caller that has
|
||||||
|
something to say about them (pull.py: "N closed, not stored") still can.
|
||||||
|
|
||||||
|
What `keep` means is the caller's business; this module only counts. Two
|
||||||
|
boundaries hold whatever it decides:
|
||||||
|
|
||||||
|
- **Stop at the limit.** The page after the one that completed the budget
|
||||||
|
is not requested — `pages` is a generator and this loop returns out of it.
|
||||||
|
- **Stop at the page budget.** A predicate that rejects everything must not
|
||||||
|
turn a bounded read into a walk of the whole tracker, so a filtered read
|
||||||
|
may scan at most `PAGE_SLACK` times the pages `limit` would need if every
|
||||||
|
payload counted. Hitting that with an unfilled budget is a warning, not a
|
||||||
|
silent short answer: the caller asked for N and is told it got fewer."""
|
||||||
|
if limit < 1:
|
||||||
|
die("--limit must be 1 or more, got %d" % limit)
|
||||||
ms_id, ms_title = (None, None)
|
ms_id, ms_title = (None, None)
|
||||||
if milestone is not None:
|
if milestone is not None:
|
||||||
ms_id, ms_title = resolve_milestone(login, base, milestone)
|
ms_id, ms_title = resolve_milestone(login, base, milestone)
|
||||||
@@ -203,10 +303,25 @@ def list_issues(login, base, state="open", labels=(), query=None,
|
|||||||
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
|
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
|
||||||
|
|
||||||
per_page = min(limit, 50)
|
per_page = min(limit, 50)
|
||||||
got = paginate(login, endpoint, limit=per_page,
|
ideal = max(1, -(-limit // per_page))
|
||||||
max_pages=max(1, -(-limit // per_page)))
|
budget = ideal if keep is None else ideal * PAGE_SLACK
|
||||||
got = [p for p in got if matches(p, ms_id, labels)]
|
|
||||||
return got[:limit], ms_title
|
got, kept, seen_pages, last_full = [], 0, 0, False
|
||||||
|
for batch in pages(login, endpoint, limit=per_page, max_pages=budget):
|
||||||
|
seen_pages += 1
|
||||||
|
last_full = len(batch) == per_page
|
||||||
|
for p in batch:
|
||||||
|
if not matches(p, ms_id, labels):
|
||||||
|
continue
|
||||||
|
got.append(p)
|
||||||
|
if keep is None or keep(p):
|
||||||
|
kept += 1
|
||||||
|
if kept >= limit:
|
||||||
|
return got, ms_title
|
||||||
|
if keep is not None and seen_pages >= budget and last_full:
|
||||||
|
warn("scanned %d page(s) and stopped %d short of --limit %d — there may"
|
||||||
|
" be more; narrow the filter or raise --limit" % (budget, limit - kept, limit))
|
||||||
|
return got, ms_title
|
||||||
|
|
||||||
|
|
||||||
def get_issue(login, base, number):
|
def get_issue(login, base, number):
|
||||||
@@ -242,7 +357,7 @@ def native_dep_pairs(login, base, number):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
|
def add_dependency(login, base, number, dep_repo, dep_number):
|
||||||
"""Make issue `number` depend on `dep_repo#dep_number`. True on success.
|
"""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):
|
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
|
||||||
@@ -261,8 +376,7 @@ def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
|
|||||||
return False
|
return False
|
||||||
payload = {"index": int(dep_number), "owner": owner, "repo": name}
|
payload = {"index": int(dep_number), "owner": owner, "repo": name}
|
||||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
|
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
|
||||||
payload_name="dep-%d-%d" % (number, dep_number),
|
payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
|
||||||
out_root=out_root, allow_fail=True)
|
|
||||||
return got is not None
|
return got is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -294,7 +408,7 @@ def ensure_labels(login, base, specs, root):
|
|||||||
continue
|
continue
|
||||||
payload = dict(spec, name=name)
|
payload = dict(spec, name=name)
|
||||||
created = api(login, "%s/labels" % base, "POST", payload,
|
created = api(login, "%s/labels" % base, "POST", payload,
|
||||||
payload_name="label-%s" % name.replace("/", "-"), out_root=root)
|
payload_name="label-%s" % name.replace("/", "-"))
|
||||||
if not created or "id" not in created:
|
if not created or "id" not in created:
|
||||||
die("could not create label %r" % name)
|
die("could not create label %r" % name)
|
||||||
cache[name] = created["id"]
|
cache[name] = created["id"]
|
||||||
@@ -317,6 +431,22 @@ def resolve_milestone_id(login, base, title):
|
|||||||
return None
|
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
|
# id map: remote key <-> local slug
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -326,7 +456,14 @@ def map_path(root):
|
|||||||
|
|
||||||
|
|
||||||
def load_map(root):
|
def load_map(root):
|
||||||
"""{"owner/repo#42": "wire-sqlc-appclick"}"""
|
"""{"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)
|
p = map_path(root)
|
||||||
if not os.path.isfile(p):
|
if not os.path.isfile(p):
|
||||||
return {}
|
return {}
|
||||||
@@ -345,9 +482,27 @@ def save_map(root, m):
|
|||||||
|
|
||||||
|
|
||||||
def rebuild_map(root, issues):
|
def rebuild_map(root, issues):
|
||||||
"""Recover the id map from the `gitea:` fields on disk. The files are the
|
"""Fold the `gitea:` fields still on disk into the id map. Returns it.
|
||||||
source of truth; .remote.json is only an index over them."""
|
|
||||||
m = {}
|
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():
|
for id, iss in issues.items():
|
||||||
key = iss.extra.get("gitea")
|
key = iss.extra.get("gitea")
|
||||||
if key:
|
if key:
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
close.py — change an issue's state in Gitea, and in the local copy with it.
|
||||||
|
|
||||||
|
The one regular tracker operation that used to have no script: closing. Without
|
||||||
|
it the only way to move `state:` was a raw `tea api -X PATCH -d '{"state":
|
||||||
|
"closed"}' repos/OWNER/REPO/issues/N`, which spells out the owner, the repo and
|
||||||
|
the request body — the three things `_gitea.py` exists to hide — and which needs
|
||||||
|
`Bash(tea api *)`, a permission that also covers `-X DELETE` on the repository.
|
||||||
|
|
||||||
|
close.py wire-sqlc-appclick one issue, by slug
|
||||||
|
close.py wire-sqlc-appclick 42 #43 several, by slug or number
|
||||||
|
close.py --reopen 42 the same thing backwards
|
||||||
|
close.py --dry-run 42 43 what would happen, no request at all
|
||||||
|
|
||||||
|
STATE ONLY. This script sends `{"state": …}` and nothing else: no title, no
|
||||||
|
body, no labels, no milestone. Editing an issue is `pull.py` -> edit ->
|
||||||
|
`push.py --update`; closing it is not an edit.
|
||||||
|
|
||||||
|
**What may be named.** A local slug, or a Gitea key (`42`, `#42`,
|
||||||
|
`owner/repo#42`, an issue URL) — the same forms `pull.py` takes. Both are
|
||||||
|
needed, and for the same reason: a push deletes the local file, so most issues
|
||||||
|
in the tracker have no slug on disk to name them by. A slug is resolved through
|
||||||
|
the file's `gitea:` field when the file is there, and through the ledger
|
||||||
|
(`.remote.json`) when push has already dropped it.
|
||||||
|
|
||||||
|
**An `origin: local` issue cannot be closed.** It is not in the tracker, so
|
||||||
|
there is nothing to close there, and the run stops naming the id rather than
|
||||||
|
quietly editing one field of a local file. Delete it, or push it first.
|
||||||
|
|
||||||
|
**Explicit ids only.** No `--milestone`, no `--label`, no "close everything
|
||||||
|
that looks done". Which issues are finished is a judgement about content; this
|
||||||
|
script only carries it out, one named id at a time. Nothing here deletes an
|
||||||
|
issue either — Gitea can, and it is not an operation of this workflow.
|
||||||
|
|
||||||
|
The local file is written only after the tracker has confirmed the write:
|
||||||
|
|
||||||
|
1. `tea` ran and exited 0 (a non-2xx exits the run inside `_gitea.api`), and
|
||||||
|
2. the answer is an object carrying the very number that was PATCHed, and
|
||||||
|
3. its `state` is the state we asked for.
|
||||||
|
|
||||||
|
Anything else and the file is left exactly as it was — see `confirmed`. An
|
||||||
|
issue whose local copy is gone (pushed and dropped) is closed in Gitea and
|
||||||
|
nothing is written; the state comes down with the next `pull.py`.
|
||||||
|
|
||||||
|
Gitea refuses to close an issue that its own dependency graph still blocks. That
|
||||||
|
refusal arrives as a non-2xx and stops the run with the tracker's own words:
|
||||||
|
close the blockers first, or unlink them in the web UI.
|
||||||
|
|
||||||
|
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 issue_index # noqa: E402
|
||||||
|
import map as gmap # noqa: E402
|
||||||
|
|
||||||
|
# What `_gitea.parse_key` accepts, asked as a question instead of an assertion:
|
||||||
|
# parse_key exits on anything it cannot read, and here "not a key" is the normal
|
||||||
|
# case — it means the argument is a slug. A slug never contains `#`, `/` or `:`,
|
||||||
|
# so the two vocabularies cannot collide.
|
||||||
|
KEY_RE = re.compile(r'^(#?\d+|[\w.-]+/[\w.-]+#\d+|https?://\S+)$')
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_key(arg):
|
||||||
|
return bool(KEY_RE.match((arg or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def ledger_pairs(remote_map, repo=None):
|
||||||
|
"""[(repo, number, slug)] from `.remote.json`, filtered to `repo`.
|
||||||
|
|
||||||
|
A `--repo` that was not given means "whatever the ledger holds": resolving
|
||||||
|
the repo's real name costs a request, and a dry run is required to make
|
||||||
|
none. The ambiguity that opens — one number under two repos — is caught at
|
||||||
|
lookup time rather than papered over."""
|
||||||
|
out = []
|
||||||
|
for key, slug in sorted(remote_map.items()):
|
||||||
|
r, n = gmap.parse_remote_key(key)
|
||||||
|
if n:
|
||||||
|
if repo is None or r == repo:
|
||||||
|
out.append((r, n, slug))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def one(candidates, what, arg):
|
||||||
|
"""The single `(repo, value)` in `candidates`, None when empty, or exit.
|
||||||
|
|
||||||
|
Two answers mean the ledger knows this number (or this slug) under more than
|
||||||
|
one repository, and only `--repo` can settle that."""
|
||||||
|
got = sorted(set(candidates))
|
||||||
|
if len(got) > 1:
|
||||||
|
_gitea.die("%r matches %s under more than one repo (%s) — pass "
|
||||||
|
"--repo owner/repo" % (arg, what, ", ".join(r for r, _v in got)))
|
||||||
|
return got[0] if got else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(arg, issues, pairs):
|
||||||
|
"""(id, number, repo) for one argument. Either of `id` and `repo` is None
|
||||||
|
when nothing this machine holds names it.
|
||||||
|
|
||||||
|
Order, and it is the order of what is most authoritative about this machine:
|
||||||
|
a file on disk, then the ledger, then nothing. A key skips straight to the
|
||||||
|
ledger — its number is already the tracker's answer, and the slug is only
|
||||||
|
wanted so the local copy, if there is one, can be kept honest.
|
||||||
|
|
||||||
|
`repo` travels out with the number because a key may name one
|
||||||
|
(`owner/repo#42`) and a `gitea:` field always does. Sending a foreign key to
|
||||||
|
whatever repo the CWD happens to be in would close somebody else's issue of
|
||||||
|
the same number, so the caller reconciles them before anything goes out."""
|
||||||
|
if looks_like_key(arg):
|
||||||
|
number, repo = _gitea.parse_key(arg)
|
||||||
|
hit = one([(r, s) for r, n, s in pairs
|
||||||
|
if n == number and (repo is None or r == repo)], "a slug", arg)
|
||||||
|
return (hit[1] if hit else None), number, repo or (hit[0] if hit else None)
|
||||||
|
|
||||||
|
iss = issues.get(arg)
|
||||||
|
if iss is not None:
|
||||||
|
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
||||||
|
if not number:
|
||||||
|
_gitea.die("%s is not in the tracker (origin: %s, no gitea: field) — "
|
||||||
|
"there is no state there to change; push.py %s first"
|
||||||
|
% (arg, iss.origin, arg))
|
||||||
|
return arg, number, repo
|
||||||
|
|
||||||
|
hit = one([(r, n) for r, n, s in pairs if s == arg], "a number", arg)
|
||||||
|
if hit:
|
||||||
|
return arg, hit[1], hit[0] # pushed, and its file went with the push
|
||||||
|
_gitea.die("no issue %r in the store or the ledger — pass a Gitea number "
|
||||||
|
"(42, #42, owner/repo#42, a URL) to close one this machine has "
|
||||||
|
"never seen" % arg)
|
||||||
|
|
||||||
|
|
||||||
|
def confirmed(got, number, state):
|
||||||
|
"""True when the tracker's answer confirms THIS write, and nothing else.
|
||||||
|
|
||||||
|
The gate in front of the local write, and deliberately boring: an answer
|
||||||
|
counts only when it is an object carrying the very number that was PATCHed
|
||||||
|
(`bool` rejected explicitly — `True` is an `int`) and the state that was
|
||||||
|
asked for. A non-2xx and a `tea` that would not run never reach here at all;
|
||||||
|
`_gitea.api` exits on both, so the file survives those by never being
|
||||||
|
written."""
|
||||||
|
if not isinstance(got, dict):
|
||||||
|
return False
|
||||||
|
n = got.get("number")
|
||||||
|
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
||||||
|
return False
|
||||||
|
return got.get("state") == state
|
||||||
|
|
||||||
|
|
||||||
|
def apply_state(root, iss, state, got):
|
||||||
|
"""Write the confirmed state onto the local file; return its path.
|
||||||
|
|
||||||
|
`state:` is the domain's own field, so it is set on the issue and written
|
||||||
|
out by the domain's own writer. The sync-owned freshness fields travel with
|
||||||
|
it: the answer that authorized this write is also the newest thing the
|
||||||
|
tracker has said about the issue, so `synced:` and `remote-updated:` are
|
||||||
|
stamped from it rather than left describing an older read."""
|
||||||
|
iss.state = state
|
||||||
|
iss.extra["synced"] = _gitea.now_iso()
|
||||||
|
if got.get("updated_at"):
|
||||||
|
iss.extra["remote-updated"] = got["updated_at"]
|
||||||
|
return issue.save(root, iss)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Close (or reopen) issues in Gitea")
|
||||||
|
ap.add_argument("ids", nargs="+",
|
||||||
|
help="local ids, or Gitea keys: 42, #42, owner/repo#42, URL")
|
||||||
|
ap.add_argument("--reopen", action="store_true",
|
||||||
|
help="set the state back to open instead of closed")
|
||||||
|
ap.add_argument("--dry-run", action="store_true",
|
||||||
|
help="print what would change; makes no request at all")
|
||||||
|
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
|
||||||
|
state = "open" if args.reopen else "closed"
|
||||||
|
verb = "reopen" if args.reopen else "close"
|
||||||
|
past = "reopened" if args.reopen else "closed"
|
||||||
|
|
||||||
|
# A store that is not there is not an error here: a number needs no local
|
||||||
|
# file, and closing an issue whose copy was dropped by push is the normal
|
||||||
|
# case. `load_all` reads an absent directory as an empty one.
|
||||||
|
issues = issue.load_all(root)
|
||||||
|
pairs = ledger_pairs(_gitea.load_map(root), args.repo)
|
||||||
|
|
||||||
|
# Every argument is resolved before anything is sent, so a typo in the third
|
||||||
|
# id does not leave the first two closed.
|
||||||
|
targets = []
|
||||||
|
for arg in args.ids:
|
||||||
|
got = resolve(arg, issues, pairs)
|
||||||
|
if got not in targets:
|
||||||
|
targets.append(got)
|
||||||
|
|
||||||
|
# One run, one repo. An explicit --repo is the operator's word and wins;
|
||||||
|
# without one, the repo comes from what the ids themselves said, and two
|
||||||
|
# answers are a question rather than a guess — `repo_base` would otherwise
|
||||||
|
# let `tea` fill the blank from the CWD and close the wrong #42.
|
||||||
|
named = {r for _i, _n, r in targets if r}
|
||||||
|
if not args.repo and len(named) > 1:
|
||||||
|
_gitea.die("all ids must belong to one repo, got: %s" % ", ".join(sorted(named)))
|
||||||
|
repo_arg = args.repo or (sorted(named)[0] if named else None)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for id, number, _repo in targets:
|
||||||
|
iss = issues.get(id)
|
||||||
|
where = ("%s (state: %s)" % (issue.path_of(root, id), iss.state)
|
||||||
|
if iss is not None else "no local copy")
|
||||||
|
print("would %s %s #%d — %s" % (verb, id or "?", number, where))
|
||||||
|
print("%d issue(s) would be %s; no request was made"
|
||||||
|
% (len(targets), past))
|
||||||
|
return
|
||||||
|
|
||||||
|
login = _gitea.require_login()
|
||||||
|
base = _gitea.repo_base(repo_arg)
|
||||||
|
|
||||||
|
touched = 0
|
||||||
|
for id, number, _repo in targets:
|
||||||
|
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH",
|
||||||
|
{"state": state}, payload_name="state-%d" % number)
|
||||||
|
# The gate. Above it nothing local has been written; below it the file
|
||||||
|
# is about to say something the tracker had better agree with.
|
||||||
|
if not confirmed(got, number, state):
|
||||||
|
_gitea.die("#%d: %s failed — the tracker's answer does not confirm the "
|
||||||
|
"write (%.200r). Nothing local was changed."
|
||||||
|
% (number, verb, got))
|
||||||
|
|
||||||
|
print("%s %s #%d %s" % (past, id or "?", number,
|
||||||
|
got.get("html_url", "")))
|
||||||
|
|
||||||
|
iss = issues.get(id)
|
||||||
|
if iss is None:
|
||||||
|
print(" no local copy — pull.py %d to get one" % number)
|
||||||
|
continue
|
||||||
|
print(" state: %s %s" % (state, apply_state(root, iss, state, got)))
|
||||||
|
touched += 1
|
||||||
|
|
||||||
|
if touched:
|
||||||
|
path, n = issue_index.build(root)
|
||||||
|
print("index: %s — %d issue(s)" % (path, n))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -73,13 +73,11 @@ def main():
|
|||||||
|
|
||||||
if args.edit:
|
if args.edit:
|
||||||
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
|
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
|
||||||
{"body": body}, payload_name="comment-%d" % args.edit,
|
{"body": body}, payload_name="comment-%d" % args.edit)
|
||||||
out_root=root)
|
|
||||||
verb = "edited"
|
verb = "edited"
|
||||||
else:
|
else:
|
||||||
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
|
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
|
||||||
{"body": body}, payload_name="comment-%s" % args.id,
|
{"body": body}, payload_name="comment-%s" % args.id)
|
||||||
out_root=root)
|
|
||||||
verb = "posted"
|
verb = "posted"
|
||||||
if not isinstance(got, dict) or "id" not in got:
|
if not isinstance(got, dict) or "id" not in got:
|
||||||
_gitea.die("%s failed, unexpected response" % verb)
|
_gitea.die("%s failed, unexpected response" % verb)
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
evict.py — ask Gitea which stored issues are closed, then evict those.
|
||||||
|
|
||||||
|
evict.py check every synced issue in the store, evict the
|
||||||
|
ones Gitea says are closed
|
||||||
|
evict.py old-thing … only these
|
||||||
|
evict.py --dry-run ask, report, change nothing
|
||||||
|
|
||||||
|
The offline command is `/tea:issue`'s `issue_evict.py`, and it is the one that
|
||||||
|
decides and deletes — this script adds exactly one thing in front of it: a
|
||||||
|
`state:` that is not stale. A local `state:` is only as fresh as the last pull,
|
||||||
|
so an issue closed in the web UI an hour ago still reads `open` here and the
|
||||||
|
offline command will (correctly) leave it alone. That is the gap this closes,
|
||||||
|
and it is the observed workflow: before this existed the operator had to
|
||||||
|
`pull.py 11 12 13 14 15` first, which re-wrote the five closed files onto disk
|
||||||
|
before anything could remove them.
|
||||||
|
|
||||||
|
Order of operations, and it is the whole safety argument:
|
||||||
|
|
||||||
|
1. every candidate's state is fetched — ALL of them, before anything is
|
||||||
|
removed;
|
||||||
|
2. each answer must be an object carrying the number we asked about and a
|
||||||
|
state from the domain's own vocabulary (`confirmed_state`);
|
||||||
|
3. only then is the eviction run, by handing the refreshed issues to
|
||||||
|
`issue_evict.run` — the same decision, the same deletion, the same
|
||||||
|
protection of `origin: local`, in one place.
|
||||||
|
|
||||||
|
A `tea` that will not run, a non-2xx, an answer for another issue, a state
|
||||||
|
nobody recognizes: the run stops at step 2 and NOTHING is deleted, not even the
|
||||||
|
issues whose answers had already arrived. That is stricter than `push.py`, which
|
||||||
|
deletes as it goes, and it costs nothing here — there is no ordering constraint
|
||||||
|
between evictions, so there is no reason to start before every answer is in.
|
||||||
|
|
||||||
|
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
|
||||||
|
none, is never asked about, and is never evicted — it is not in the tracker to
|
||||||
|
be closed. An `origin: gitea` issue whose handle is missing or unparseable
|
||||||
|
cannot be verified, so it is reported and kept rather than guessed at.
|
||||||
|
|
||||||
|
Cost: one GET per candidate. The store is a working set that push keeps small,
|
||||||
|
and a wrong answer here deletes a file, so each issue is asked about by its own
|
||||||
|
address rather than inferred from a list that a `--limit` could have truncated.
|
||||||
|
|
||||||
|
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_evict # noqa: E402
|
||||||
|
import map as gmap # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def candidates(issues, ids=None):
|
||||||
|
"""(checkable, unverifiable) — which issues the tracker can be asked about.
|
||||||
|
|
||||||
|
checkable is [(id, repo, number)] read off the `gitea:` handle, so an issue
|
||||||
|
that lives in another repo is asked about there. unverifiable is
|
||||||
|
[(id, why)]: it names a tracker but carries no handle to reach it by, which
|
||||||
|
is a file to report, never one to delete on a guess.
|
||||||
|
|
||||||
|
An `origin: local` issue is in neither list. It has no handle because it has
|
||||||
|
never left this machine, and asking Gitea about it is not a question that
|
||||||
|
has an answer.
|
||||||
|
"""
|
||||||
|
checkable, unverifiable = [], []
|
||||||
|
for id in (list(ids) if ids else sorted(issues)):
|
||||||
|
iss = issues[id]
|
||||||
|
if iss.is_local:
|
||||||
|
continue
|
||||||
|
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
||||||
|
if not repo or not number:
|
||||||
|
unverifiable.append((id, "origin: %s but no usable `gitea:` handle"
|
||||||
|
% iss.origin))
|
||||||
|
continue
|
||||||
|
checkable.append((id, repo, number))
|
||||||
|
return checkable, unverifiable
|
||||||
|
|
||||||
|
|
||||||
|
def confirmed_state(got, number):
|
||||||
|
"""The state Gitea confirmed for `number`, or None — the deletion gate.
|
||||||
|
|
||||||
|
The counterpart of `push.confirmed_number`, and written the same way: boring,
|
||||||
|
and saying no by default, because everything downstream of a `str` return
|
||||||
|
here may delete a file. An answer counts only when it is a dict, carries the
|
||||||
|
very number we asked about, and names a state the domain recognizes.
|
||||||
|
|
||||||
|
`bool` is rejected explicitly: `True` is an `int` in Python, and an answer
|
||||||
|
about issue `true` is not an answer about issue 42.
|
||||||
|
|
||||||
|
What it does not have to catch, because it never gets here: a non-2xx or a
|
||||||
|
`tea` that would not run at all — `_gitea.api` exits on both.
|
||||||
|
"""
|
||||||
|
if not isinstance(got, dict):
|
||||||
|
return None
|
||||||
|
n = got.get("number")
|
||||||
|
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
||||||
|
return None
|
||||||
|
state = got.get("state")
|
||||||
|
return state if state in issue.STATES else None
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Evict issues Gitea reports as closed from the local store")
|
||||||
|
ap.add_argument("ids", nargs="*",
|
||||||
|
help="issue ids (default: every synced issue in the store)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true",
|
||||||
|
help="ask the tracker and report; write and delete nothing")
|
||||||
|
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||||
|
help="store root (default: <repo>/tmp/issues)")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
root = args.out
|
||||||
|
if not issue.store_exists(root):
|
||||||
|
_gitea.die("store %s does not exist — nothing to evict" % root)
|
||||||
|
issues = issue.load_all(root)
|
||||||
|
missing = [i for i in args.ids if i not in issues]
|
||||||
|
if missing:
|
||||||
|
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
||||||
|
|
||||||
|
checkable, unverifiable = candidates(issues, args.ids)
|
||||||
|
for id, why in unverifiable:
|
||||||
|
_gitea.warn("%s: %s — kept, and not asked about" % (id, why))
|
||||||
|
if not checkable:
|
||||||
|
print("nothing to check: no issue in the store carries a `gitea:` handle")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
login = _gitea.require_login()
|
||||||
|
|
||||||
|
# ---- every answer first, deletions after -----------------------------
|
||||||
|
fresh = {}
|
||||||
|
for id, repo, number in checkable:
|
||||||
|
got = _gitea.api(login, "%s/issues/%d" % (_gitea.repo_base(repo), number))
|
||||||
|
state = confirmed_state(got, number)
|
||||||
|
if state is None:
|
||||||
|
_gitea.die("%s: the tracker's answer for %s#%d does not confirm a state "
|
||||||
|
"(%.200r). Nothing was evicted."
|
||||||
|
% (id, repo, number, got))
|
||||||
|
fresh[id] = state
|
||||||
|
|
||||||
|
# The store stops lying even about the issues that stay: an answer already
|
||||||
|
# paid for is written back when it disagrees with the file. This is the only
|
||||||
|
# write this script makes, and a dry run makes none.
|
||||||
|
for id, state in sorted(fresh.items()):
|
||||||
|
was = issues[id].state
|
||||||
|
if was == state:
|
||||||
|
continue
|
||||||
|
print("state %s %s -> %s" % (id, was, state))
|
||||||
|
issues[id].state = state
|
||||||
|
if not args.dry_run:
|
||||||
|
issue.save(root, issues[id])
|
||||||
|
|
||||||
|
issue_evict.run(root, issues, [id for id, _, _ in checkable], args.dry_run)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -32,6 +32,11 @@ 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
|
Only repository labels are read; an organization's own labels sit behind a
|
||||||
different endpoint and are neither read nor written.
|
different endpoint and are neither read nor written.
|
||||||
|
|
||||||
|
The issue store is out of scope too, and not incidentally. A label belongs to
|
||||||
|
the repository, not to any issue, so this command neither reads tmp/issues/ nor
|
||||||
|
creates it — the taxonomy it paints comes from the domain MODULE, and the
|
||||||
|
request bodies it sends go to the transport's own tmp/payload/.
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -185,8 +190,7 @@ def main():
|
|||||||
continue
|
continue
|
||||||
payload = dict(spec, name=name)
|
payload = dict(spec, name=name)
|
||||||
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
|
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
|
||||||
payload_name="label-%s" % name.replace("/", "-"),
|
payload_name="label-%s" % name.replace("/", "-"))
|
||||||
out_root=issue.ISSUE_ROOT)
|
|
||||||
if not new or "id" not in new:
|
if not new or "id" not in new:
|
||||||
_gitea.die("could not create label %r" % name)
|
_gitea.die("could not create label %r" % name)
|
||||||
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
|
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
|
||||||
@@ -211,8 +215,7 @@ def main():
|
|||||||
for field, _is, _want in drift:
|
for field, _is, _want in drift:
|
||||||
patch[field] = spec[field]
|
patch[field] = spec[field]
|
||||||
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
|
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
|
||||||
payload_name="label-%s" % name.replace("/", "-"),
|
payload_name="label-%s" % name.replace("/", "-"))
|
||||||
out_root=issue.ISSUE_ROOT)
|
|
||||||
fixed += 1
|
fixed += 1
|
||||||
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
|
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||||
|
|
||||||
|
|||||||
+105
-8
@@ -15,11 +15,13 @@ What crosses the boundary, and what does not:
|
|||||||
|
|
||||||
domain Gitea note
|
domain Gitea note
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
id (slug) — local only; the tracker never sees it
|
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
|
title title verbatim, both ways
|
||||||
body body verbatim up, verbatim down except
|
body body verbatim up, verbatim down except
|
||||||
checkbox state — see
|
the marker and checkbox state — see
|
||||||
merge_checkbox_state
|
with_id_marker / merge_checkbox_state
|
||||||
state state open/closed, same vocabulary
|
state state open/closed, same vocabulary
|
||||||
labels labels[] names both ways; ids only on write
|
labels labels[] names both ways; ids only on write
|
||||||
assignees assignees[] logins
|
assignees assignees[] logins
|
||||||
@@ -33,8 +35,14 @@ What crosses the boundary, and what does not:
|
|||||||
directions: a pull seeds `depends:` from the `#N` it finds there, and a push
|
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
|
never rewrites what the author wrote. Deliberate — a translator that edits
|
||||||
prose churns the body on every round trip.
|
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 os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.normpath(os.path.join(
|
sys.path.insert(0, os.path.normpath(os.path.join(
|
||||||
@@ -97,6 +105,85 @@ def parse_remote_key(key):
|
|||||||
return (repo, int(num)) if repo and num.isdigit() else (None, None)
|
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
|
# Gitea -> domain
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -158,8 +245,14 @@ def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=Non
|
|||||||
`local_body` is the body of the copy already in the store, when there is
|
`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
|
one. It contributes exactly one thing: its ticked checkboxes survive the
|
||||||
overwrite (merge_checkbox_state). Pass None and the remote body is taken
|
overwrite (merge_checkbox_state). Pass None and the remote body is taken
|
||||||
whole, which is what a first pull does."""
|
whole, which is what a first pull does.
|
||||||
body = merge_checkbox_state((payload.get("body") or "").strip(), local_body)
|
|
||||||
|
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 {}
|
id_for_number = id_for_number or {}
|
||||||
|
|
||||||
numbers = list(numbers_in_body(body))
|
numbers = list(numbers_in_body(body))
|
||||||
@@ -220,9 +313,13 @@ def render_comments(comments):
|
|||||||
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
|
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
|
||||||
"""Request body for POST /issues or PATCH /issues/{n}.
|
"""Request body for POST /issues or PATCH /issues/{n}.
|
||||||
|
|
||||||
The body is sent verbatim — see the module docstring on why slugs in
|
The prose is sent verbatim — see the module docstring on why slugs in
|
||||||
`## Depends on` are not rewritten to `#N`."""
|
`## Depends on` are not rewritten to `#N`. The one addition is the id
|
||||||
payload = {"title": iss.title, "body": iss.body.strip()}
|
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:
|
if label_ids is not None:
|
||||||
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
|
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
|
||||||
if iss.assignees:
|
if iss.assignees:
|
||||||
|
|||||||
+165
-44
@@ -3,10 +3,15 @@
|
|||||||
pull.py — Gitea issues -> the local store.
|
pull.py — Gitea issues -> the local store.
|
||||||
|
|
||||||
Writes flat markdown the domain layer owns and prints a compact index; the raw
|
Writes flat markdown the domain layer owns and prints a compact index; the raw
|
||||||
API payload never reaches the conversation. An issue already in the store keeps
|
API payload never reaches the conversation.
|
||||||
its slug even when its title changes on the server — identity is the local id,
|
|
||||||
matched through tmp/issues/.remote.json (and recoverable from the `gitea:`
|
**This is how you get a pushed issue back.** `push.py` deletes the local file
|
||||||
fields if that file is lost).
|
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:
|
Two ways to name what to pull:
|
||||||
|
|
||||||
@@ -23,11 +28,31 @@ 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
|
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
|
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 —
|
`--state closed` writes one. An issue already on disk is refreshed either way,
|
||||||
an issue already on disk is refreshed either way, so the local copy learns it
|
so the local copy learns it was closed instead of staying open forever, and the
|
||||||
was closed instead of staying open forever, and the count of the ones left out
|
count of the ones left out goes to stderr. Key mode is exempt: an address is not
|
||||||
goes to stderr. Key mode is exempt: an address is not a bulk read, and
|
a bulk read, and `pull.py 1` fetches a closed issue as it always did.
|
||||||
`pull.py 1` fetches a closed issue as it always did.
|
|
||||||
|
**`--limit` is on the write, not on the selection.** It counts the issues this
|
||||||
|
run puts in the store — written, or left in place by `--cached` — and never the
|
||||||
|
closed ones it enumerated and threw away. `--limit 20` over a milestone whose
|
||||||
|
first 30 issues are closed still writes 20, if 20 open ones are there to write:
|
||||||
|
pages keep coming until the budget is full. Two boundaries keep that honest:
|
||||||
|
|
||||||
|
- Pages stop the moment the budget is full. Never one page more.
|
||||||
|
- A filtered read may scan at most `_gitea.PAGE_SLACK` times the pages the limit
|
||||||
|
would need if nothing were dropped. A filter that matches almost only closed
|
||||||
|
issues therefore ends in a warning and a short answer, not in a walk of the
|
||||||
|
whole tracker. Narrow the filter, or raise `--limit`, which raises the budget
|
||||||
|
with it.
|
||||||
|
- Dependencies are outside the count: a blocker is followed because a stored
|
||||||
|
issue named it, not because the filter selected it. `--limit 20` can
|
||||||
|
therefore leave more than 20 files behind — the budget counts the selection's
|
||||||
|
writes, and the graph is not part of the selection.
|
||||||
|
|
||||||
|
`remote.py` is the deliberate exception, and it is not the same flag twice: it
|
||||||
|
writes nothing at all, so there is no write to bound and its `--limit` means
|
||||||
|
what it says — how many lines to print.
|
||||||
|
|
||||||
Comments ride along by default, in both modes and for every issue written:
|
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
|
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
|
||||||
@@ -37,8 +62,39 @@ 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
|
never "not asked for". The thread is pull-only: editing it changes nothing in
|
||||||
Gitea (post with comment.py).
|
Gitea (post with comment.py).
|
||||||
|
|
||||||
|
**Dependencies come with every pull.** A pull answers with the whole unit of
|
||||||
|
work — the issue and what blocks it — so `depends:` is filled from Gitea's
|
||||||
|
native dependency graph and every blocker is pulled too, recursively, down to
|
||||||
|
`--depth` (default 3). That graph is the only source there is: `map.from_api`
|
||||||
|
writes slugs into the `## Depends on` prose and never `#N`, so an edge cannot be
|
||||||
|
recovered from the body. `--no-deps` turns off both halves — no `depends:`, no
|
||||||
|
recursion, and no request spent on either. `--deps` is still accepted and now
|
||||||
|
does nothing; it names what already happens.
|
||||||
|
|
||||||
|
What it costs, stated rather than hidden:
|
||||||
|
|
||||||
|
- **One request per issue that lands in the store** — `GET …/issues/{n}/dependencies`,
|
||||||
|
fetched once and used twice, since the same links both fill `depends:` and
|
||||||
|
tell the walk where to go next. A closed issue that filter mode drops costs
|
||||||
|
nothing: nothing was stored, so there is no unit of work to complete.
|
||||||
|
- **One request per blocker the selection did not already carry** — a `GET` for
|
||||||
|
the issue itself, then its own links, and so on until `--depth`.
|
||||||
|
- So `--milestone X` over 50 open issues is one list request + 50 link requests
|
||||||
|
+ one pair for every blocker outside the milestone, where it used to be one
|
||||||
|
request flat. `--no-deps` is the way back to one.
|
||||||
|
|
||||||
|
**In filter mode a blocker the filter did not select still lands in the store,
|
||||||
|
and that is deliberate.** `--milestone X` can leave an issue from milestone Y on
|
||||||
|
disk and `--label` an unlabelled one: a blocker is followed because a stored
|
||||||
|
issue names it, not because it matched. The one blocker that does not land is a
|
||||||
|
closed one — closed is not a unit of work, filter mode drops it the way it drops
|
||||||
|
any other closed issue, and the `depends:` edge to it goes with it, so nothing
|
||||||
|
points at a file that is not there. Key mode has no such rule and stores it.
|
||||||
|
|
||||||
Other flags:
|
Other flags:
|
||||||
--deps [--depth N] follow dependencies and pull them too
|
--no-deps do not fill depends:, do not follow blockers
|
||||||
|
--deps accepted, does nothing: it is the default now
|
||||||
|
--depth N how deep to follow blockers (default 3)
|
||||||
--cached skip issues already on disk (body AND comments)
|
--cached skip issues already on disk (body AND comments)
|
||||||
--repo owner/repo default: auto-detect from the CWD git remote
|
--repo owner/repo default: auto-detect from the CWD git remote
|
||||||
|
|
||||||
@@ -47,7 +103,9 @@ 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
|
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
|
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
|
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
|
not read and not merged — it still costs its one link request, because a cached
|
||||||
|
issue's blockers can be missing from disk even when it is not (`--cached
|
||||||
|
--no-deps` is the free one). Draw the graph afterwards with the domain's own
|
||||||
issue_tree.py — it needs no network.
|
issue_tree.py — it needs no network.
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||||
@@ -66,17 +124,54 @@ import map as gmap # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
def id_for(payload, store_ids, remote_map, repo, root):
|
def id_for(payload, store_ids, remote_map, repo, root):
|
||||||
"""Existing slug for this remote issue, or a fresh unique one. A retitled
|
"""The slug this remote issue belongs under. Three sources, in order.
|
||||||
issue keeps the slug it was first pulled under — the map is by number."""
|
|
||||||
|
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"]))
|
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
|
||||||
if got:
|
if got:
|
||||||
return got
|
return got
|
||||||
return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids)
|
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 lands_in_store(payload, drop_closed, store_ids, remote_map, repo, root):
|
||||||
|
"""Would this payload leave a file in the store? The `--limit` predicate.
|
||||||
|
|
||||||
|
It has to be the same test the walk below applies, or the budget is spent on
|
||||||
|
issues that never land — which is the bug this exists to prevent. So: a
|
||||||
|
closed issue counts only when the store already has it (it is refreshed, and
|
||||||
|
that is a write); anything else counts, including one `--cached` will skip,
|
||||||
|
because a skipped issue is still an issue the store holds when the run ends.
|
||||||
|
|
||||||
|
Cheap in the common case: only a closed payload costs an `id_for`, and that
|
||||||
|
is a lookup plus, at worst, a stat."""
|
||||||
|
if not (drop_closed and payload.get("state") == "closed"):
|
||||||
|
return True
|
||||||
|
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||||
|
return os.path.isfile(issue.path_of(root, id))
|
||||||
|
|
||||||
|
|
||||||
def comments_path(root, id):
|
def comments_path(root, id):
|
||||||
"""Where an issue's comment thread lives — beside it, under the same slug."""
|
"""Where an issue's comment thread lives — beside it, under the same slug.
|
||||||
return os.path.join(root, "%s.comments.md" % id)
|
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):
|
def sync_comments(login, base, root, id, number, count):
|
||||||
@@ -106,8 +201,18 @@ def main():
|
|||||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
ap.add_argument("-q", "--query", help="search text in title/body")
|
||||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
|
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
|
||||||
help="filter mode only (default: open)")
|
help="filter mode only (default: open)")
|
||||||
ap.add_argument("--limit", type=int, default=100, help="filter mode cap (default: 100)")
|
ap.add_argument("--limit", type=int, default=100,
|
||||||
ap.add_argument("--deps", action="store_true", help="follow dependencies and pull them")
|
help="filter mode: how many issues to STORE, not to enumerate"
|
||||||
|
" (default: 100)")
|
||||||
|
# Dependencies are the default: a pull answers with the unit of work, not
|
||||||
|
# one row of it. `--deps` stays accepted so the calls and command tables
|
||||||
|
# written against the old default keep working — it now sets what is
|
||||||
|
# already set.
|
||||||
|
ap.add_argument("--no-deps", dest="deps", action="store_false",
|
||||||
|
help="do not fill depends: and do not follow blockers")
|
||||||
|
ap.add_argument("--deps", dest="deps", action="store_true",
|
||||||
|
help="accepted, does nothing: dependencies are followed by default")
|
||||||
|
ap.set_defaults(deps=True)
|
||||||
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
|
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
|
||||||
ap.add_argument("--cached", action="store_true",
|
ap.add_argument("--cached", action="store_true",
|
||||||
help="skip issues already on disk instead of refetching")
|
help="skip issues already on disk instead of refetching")
|
||||||
@@ -155,9 +260,14 @@ def main():
|
|||||||
|
|
||||||
# ---- seeds -----------------------------------------------------------
|
# ---- seeds -----------------------------------------------------------
|
||||||
if filtered:
|
if filtered:
|
||||||
|
# The limit bounds the write, so the transport is told what a write is
|
||||||
|
# and counts those; the closed ones it enumerated on the way come back
|
||||||
|
# in the list anyway, to be reported and dropped below.
|
||||||
payloads, ms_title = _gitea.list_issues(
|
payloads, ms_title = _gitea.list_issues(
|
||||||
login, base, state=args.state, labels=args.label, query=args.query,
|
login, base, state=args.state, labels=args.label, query=args.query,
|
||||||
milestone=args.milestone, limit=args.limit)
|
milestone=args.milestone, limit=args.limit,
|
||||||
|
keep=lambda p: lands_in_store(p, drop_closed, store_ids, remote_map,
|
||||||
|
repo, root))
|
||||||
if not payloads:
|
if not payloads:
|
||||||
_gitea.die("no issues match that filter")
|
_gitea.die("no issues match that filter")
|
||||||
what = []
|
what = []
|
||||||
@@ -183,35 +293,42 @@ def main():
|
|||||||
stored = os.path.isfile(issue.path_of(root, id))
|
stored = os.path.isfile(issue.path_of(root, id))
|
||||||
|
|
||||||
# Closed and not already ours: nothing is written and nothing is asked
|
# 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
|
# of the server for it — not its comments, not its links, and its own
|
||||||
# too, so no other issue ends up pointing `depends:` at a missing file.
|
# blockers are not followed. 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:
|
if drop_closed and payload.get("state") == "closed" and not stored:
|
||||||
dropped.append(number)
|
dropped.append(number)
|
||||||
|
continue # not stored: no unit of work here, so no links are fetched
|
||||||
|
|
||||||
|
store_ids.add(id)
|
||||||
|
number_of_id[number] = id
|
||||||
|
|
||||||
|
# The native links, fetched ONCE for the two things they are for:
|
||||||
|
# filling this issue's `depends:` and telling the walk where to go next.
|
||||||
|
# One request per issue that lands in the store, and only one — the cost
|
||||||
|
# the docstring quotes is this line.
|
||||||
|
deps = _gitea.native_deps(login, base, number) if args.deps else []
|
||||||
|
|
||||||
|
if args.cached and stored:
|
||||||
|
skipped.append(id) # body and thread unread; only the links cost
|
||||||
else:
|
else:
|
||||||
store_ids.add(id)
|
# The copy already on disk, as it was when this run started. It
|
||||||
number_of_id[number] = id
|
# contributes its ticked checkboxes and nothing else; None when
|
||||||
if args.cached and stored:
|
# the store has never seen this issue.
|
||||||
skipped.append(id) # untouched, unread, and not one request spent
|
prev = issues.get(id)
|
||||||
else:
|
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||||
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
id_for_number=number_of_id,
|
||||||
# The copy already on disk, as it was when this run started. It
|
extra_numbers=deps,
|
||||||
# contributes its ticked checkboxes and nothing else; None when
|
synced=_gitea.now_iso(),
|
||||||
# the store has never seen this issue.
|
local_body=prev.body if prev else None)
|
||||||
prev = issues.get(id)
|
issue.save(root, iss)
|
||||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
||||||
id_for_number=number_of_id,
|
remote_map[gmap.remote_key(repo, number)] = id
|
||||||
extra_numbers=extra,
|
written.append(id)
|
||||||
synced=_gitea.now_iso(),
|
pending.append((id, unresolved))
|
||||||
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:
|
if args.deps and depth < args.depth:
|
||||||
child_numbers = (gmap.numbers_in_body(payload.get("body") or "")
|
child_numbers = gmap.numbers_in_body(payload.get("body") or "") + deps
|
||||||
+ _gitea.native_deps(login, base, number))
|
|
||||||
for n in child_numbers:
|
for n in child_numbers:
|
||||||
if n in seen_numbers:
|
if n in seen_numbers:
|
||||||
continue
|
continue
|
||||||
@@ -240,8 +357,10 @@ def main():
|
|||||||
|
|
||||||
# Compact output — the only thing that lands in the model's context. The
|
# 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.
|
# thread rides on the issue's own line; no file means no comments.
|
||||||
|
graph = False
|
||||||
for id in sorted(set(written) | set(skipped)):
|
for id in sorted(set(written) | set(skipped)):
|
||||||
iss = issue.load(root, id)
|
iss = issue.load(root, id)
|
||||||
|
graph = graph or bool(iss.depends)
|
||||||
note = " (cached)" if id in skipped else ""
|
note = " (cached)" if id in skipped else ""
|
||||||
cpath = comments_path(root, id)
|
cpath = comments_path(root, id)
|
||||||
if os.path.isfile(cpath):
|
if os.path.isfile(cpath):
|
||||||
@@ -250,7 +369,9 @@ def main():
|
|||||||
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
||||||
issue.path_of(root, id), note))
|
issue.path_of(root, id), note))
|
||||||
print("index: %s" % index_path)
|
print("index: %s" % index_path)
|
||||||
if args.deps:
|
# Now that dependencies are the default, the hint is worth printing when
|
||||||
|
# there is something to draw, not on every run that could have drawn it.
|
||||||
|
if graph:
|
||||||
print("graph: run issue_tree.py (offline) to draw it")
|
print("graph: run issue_tree.py (offline) to draw it")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+168
-36
@@ -1,16 +1,36 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
push.py — local store -> Gitea.
|
push.py — local store -> Gitea, and the local copy goes away.
|
||||||
|
|
||||||
Pushing is additive. The local file is never deleted and never moves: it gains
|
**A successful push deletes `tmp/issues/<id>.md` and `<id>.comments.md`.** Once
|
||||||
`gitea:`, `url:` and `synced:`, and `origin:` flips from `local` to `gitea`.
|
the tracker has the issue, the tracker IS the issue: what is left in the store
|
||||||
One issue, two places it is visible — not two kinds of file. A local-only issue
|
is only what has not left this machine. Get it back with `pull.py <n>` — it
|
||||||
is a finished state, not a step on the way to a tracker.
|
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 every local-only issue, dependencies first
|
||||||
push.py wire-sqlc-appclick one issue
|
push.py wire-sqlc-appclick one issue
|
||||||
push.py --update <id …> PATCH issues that are already in Gitea
|
push.py --update <id …> PATCH issues that are already in Gitea
|
||||||
push.py --dry-run validate only, no network
|
push.py --dry-run validate only, no network, nothing deleted
|
||||||
|
|
||||||
Before anything is sent, each issue is validated against the canonical format
|
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,
|
by the domain layer (exactly one type/*, English title with no type prefix,
|
||||||
@@ -24,7 +44,7 @@ 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:`
|
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
|
entry that also has one becomes a **native Gitea link** — the same
|
||||||
`/dependencies` that `pull.py --deps` reads back, so the tracker shows the
|
`/dependencies` that every `pull.py` reads back, so the tracker shows the
|
||||||
blocking panel and refuses to close a blocked issue first. Topological order
|
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.
|
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
|
`--update` links whatever appeared in `depends:` since the last push. A link
|
||||||
@@ -44,9 +64,11 @@ Missing labels are created with the canonical color and, for type/* and
|
|||||||
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
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
|
`branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is
|
||||||
filled with the current git branch and written back to the file; one that is
|
filled with the current git branch and goes up with the issue; one that is
|
||||||
already set is never touched. Detached HEAD, or no repo at all: no `ref` is
|
already set is sent as written and never overwritten. Detached HEAD, or no repo
|
||||||
sent and a warning says so.
|
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).
|
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||||
"""
|
"""
|
||||||
@@ -85,27 +107,98 @@ def select(issues, ids, update):
|
|||||||
return chosen
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
def dep_state(iss, issues, pushing):
|
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.
|
"""What each `depends:` entry is, as far as linking is concerned.
|
||||||
|
|
||||||
Yields (slug, remote_key, in_run) per dependency that exists in the store:
|
Yields (slug, remote_key, in_run) per dependency this run can say anything
|
||||||
|
about:
|
||||||
|
|
||||||
remote_key the dependency's `gitea:` value, or None while it is local
|
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
|
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 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.
|
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
|
`--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."""
|
links are coming and which cannot exist at all."""
|
||||||
|
key_of_id = key_of_id or {}
|
||||||
out = []
|
out = []
|
||||||
for d in iss.depends:
|
for d in iss.depends:
|
||||||
dep = issues.get(d)
|
dep = issues.get(d)
|
||||||
if dep is None:
|
key = (dep.extra.get("gitea") if dep is not None else None) or key_of_id.get(d)
|
||||||
continue # not in the store; validate() already warned
|
if dep is None and not key:
|
||||||
out.append((d, dep.extra.get("gitea") or None, d in pushing))
|
continue
|
||||||
|
out.append((d, key or None, d in pushing))
|
||||||
return out
|
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():
|
def git_branch():
|
||||||
"""The branch HEAD is on, or None. The only git call these scripts make —
|
"""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
|
read, never write. A detached HEAD prints `HEAD` and outside a repo git
|
||||||
@@ -164,7 +257,9 @@ def main():
|
|||||||
# ---- branch: -> Gitea `ref` ------------------------------------------
|
# ---- branch: -> Gitea `ref` ------------------------------------------
|
||||||
# Only an empty field is filled: a branch written by hand is the author's
|
# 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,
|
# 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`.
|
# 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)]
|
blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)]
|
||||||
branch = git_branch() if blank else None
|
branch = git_branch() if blank else None
|
||||||
if branch:
|
if branch:
|
||||||
@@ -178,13 +273,16 @@ def main():
|
|||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
links = 0
|
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:
|
for id in order:
|
||||||
iss = issues[id]
|
iss = issues[id]
|
||||||
print("ok %s [type/%s] %s (%s)"
|
print("ok %s [type/%s] %s (%s)"
|
||||||
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
|
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
|
||||||
# Not one request is made here: everything below is read off the
|
# Not one request is made here: everything below is read off the
|
||||||
# store. `#?` is a number this run has not handed out yet.
|
# store. `#?` is a number this run has not handed out yet.
|
||||||
for slug, key, in_run in dep_state(iss, issues, pushing):
|
for slug, key, in_run in dep_state(iss, issues, pushing, key_of_id):
|
||||||
if key:
|
if key:
|
||||||
print(" link -> %s (%s)" % (key, slug))
|
print(" link -> %s (%s)" % (key, slug))
|
||||||
links += 1
|
links += 1
|
||||||
@@ -207,13 +305,17 @@ def main():
|
|||||||
|
|
||||||
milestone_ids = {}
|
milestone_ids = {}
|
||||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
||||||
|
key_of_id = ledger_keys(remote_map, repo)
|
||||||
|
|
||||||
for id in order:
|
for id in order:
|
||||||
iss = issues[id]
|
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
|
unsynced = [d for d in iss.depends
|
||||||
if d in issues and not issues[d].extra.get("gitea")
|
if d in issues and not issues[d].extra.get("gitea")
|
||||||
and d not in pushing]
|
and d not in key_of_id and d not in pushing]
|
||||||
if unsynced:
|
if unsynced:
|
||||||
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
||||||
% (id, ", ".join(unsynced)))
|
% (id, ", ".join(unsynced)))
|
||||||
@@ -228,20 +330,32 @@ def main():
|
|||||||
_gitea.warn("%s: milestone %r does not exist in %s — not set"
|
_gitea.warn("%s: milestone %r does not exist in %s — not set"
|
||||||
% (id, iss.milestone, repo))
|
% (id, iss.milestone, repo))
|
||||||
|
|
||||||
number = gmap.number_of(iss)
|
sent_number = gmap.number_of(iss)
|
||||||
if number:
|
if sent_number:
|
||||||
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
||||||
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH", payload,
|
got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH",
|
||||||
payload_name="issue-%s" % id, out_root=root)
|
payload, payload_name="issue-%s" % id)
|
||||||
verb = "updated"
|
verb = "updated"
|
||||||
else:
|
else:
|
||||||
payload = gmap.to_payload(iss, label_ids, ms_id)
|
payload = gmap.to_payload(iss, label_ids, ms_id)
|
||||||
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
|
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
|
||||||
payload_name="issue-%s" % id, out_root=root)
|
payload_name="issue-%s" % id)
|
||||||
verb = "created"
|
verb = "created"
|
||||||
if not isinstance(got, dict) or "number" not in got:
|
# The gate. Below this line the local file is going to be deleted, so
|
||||||
_gitea.die("%s: %s failed, unexpected response" % (id, verb))
|
# anything short of a confirmed write has to stop the run here.
|
||||||
number = got["number"]
|
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
|
# Gitea occasionally drops labels on create — re-apply rather than
|
||||||
# trust the echo.
|
# trust the echo.
|
||||||
@@ -250,31 +364,49 @@ def main():
|
|||||||
if missing:
|
if missing:
|
||||||
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
|
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
|
||||||
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
|
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
|
||||||
payload_name="labels-%s" % id, out_root=root)
|
payload_name="labels-%s" % id)
|
||||||
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
|
_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())
|
gmap.apply_remote(iss, got, repo, _gitea.now_iso())
|
||||||
issue.save(root, iss)
|
|
||||||
remote_map[gmap.remote_key(repo, number)] = id
|
# 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", "")))
|
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
|
||||||
|
|
||||||
# ---- the graph, as Gitea's own links ------------------------------
|
# ---- the graph, as Gitea's own links ------------------------------
|
||||||
# Blockers came first in topological order, so each one that is going
|
# Blockers came first in topological order, so each one that is going
|
||||||
# to have a number has one already — the store was stamped in place.
|
# to have a number has one already — stamped on the in-memory issue
|
||||||
# The GET is the idempotence check: it costs one request per issue that
|
# above, or read out of the ledger for one whose file an earlier push
|
||||||
# has dependencies at all, and it is what makes a repeat push a no-op.
|
# 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))
|
wanted_links = [(slug, gmap.parse_remote_key(key))
|
||||||
for slug, key, _ in dep_state(iss, issues, pushing) if key]
|
for slug, key, _ in dep_state(iss, issues, pushing, key_of_id)
|
||||||
|
if key]
|
||||||
if wanted_links:
|
if wanted_links:
|
||||||
have = _gitea.native_dep_pairs(login, base, number)
|
have = _gitea.native_dep_pairs(login, base, number)
|
||||||
for slug, (drepo, dnum) in wanted_links:
|
for slug, (drepo, dnum) in wanted_links:
|
||||||
if not dnum or (drepo, dnum) in have:
|
if not dnum or (drepo, dnum) in have:
|
||||||
continue
|
continue
|
||||||
if _gitea.add_dependency(login, base, number, drepo, dnum, root):
|
if _gitea.add_dependency(login, base, number, drepo, dnum):
|
||||||
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
|
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
|
||||||
else:
|
else:
|
||||||
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
|
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
|
||||||
"hand or re-run push" % (id, number, drepo, dnum, slug))
|
"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)
|
_gitea.save_map(root, remote_map)
|
||||||
path, n = issue_index.build(root)
|
path, n = issue_index.build(root)
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ Usage:
|
|||||||
remote.py [--state open|closed|all] [--label L]… [-q TEXT]
|
remote.py [--state open|closed|all] [--label L]… [-q TEXT]
|
||||||
[--milestone M] [--limit N] [--repo owner/repo]
|
[--milestone M] [--limit N] [--repo owner/repo]
|
||||||
|
|
||||||
|
`--limit` here caps the LISTING: N lines out, closed ones among them. That is
|
||||||
|
not what the same flag means to `pull.py`, and the difference is not an
|
||||||
|
oversight — pull.py bounds what it writes, and this command writes nothing, so
|
||||||
|
there is nothing else for a limit to bound. Enumeration is the whole job.
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
|||||||
+36
-1
@@ -58,6 +58,37 @@ 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`.
|
||||||
|
|
||||||
|
### `--repo` takes a slug — except where a checkout is required
|
||||||
|
|
||||||
|
A few commands touch local git, not just the API, and for those `--repo`
|
||||||
|
**must be a path to a checkout**; a slug is rejected:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: local repository required: execute from a repo dir, or specify a path with --repo
|
||||||
|
```
|
||||||
|
|
||||||
|
The message reads like the flag is missing even when it was passed. Confirmed
|
||||||
|
for `pulls create`, `pulls checkout` and `pulls clean` (tea 0.14.x). Everything
|
||||||
|
that is only an API call — `pulls list`, `milestones`, `releases`, `times`,
|
||||||
|
`labels`, `issues` — takes the slug from any directory.
|
||||||
|
|
||||||
|
Three working forms for `pulls create`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. cwd inside the checkout, no --repo at all
|
||||||
|
tea pulls create --login "$GITEA_LOGIN" --head feat/x --base main \
|
||||||
|
--title "…" --description "…"
|
||||||
|
|
||||||
|
# 2. from anywhere, --repo as a PATH (this is also the git-worktree answer:
|
||||||
|
# point it at the main checkout)
|
||||||
|
tea pulls create --login "$GITEA_LOGIN" --repo /path/to/checkout \
|
||||||
|
--head feat/x --base main --title "…" --description "…"
|
||||||
|
|
||||||
|
# 3. no checkout in reach — POST it, where owner/repo is a slug again
|
||||||
|
tea api --login "$GITEA_LOGIN" -X POST -d @tmp/pull/x.json \
|
||||||
|
repos/{owner}/{repo}/pulls
|
||||||
|
```
|
||||||
|
|
||||||
## 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
|
||||||
@@ -125,7 +156,11 @@ are still fine via entity commands. Always the placeholder, never a login name.
|
|||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
- Pass `-o json` for structured output when parsing programmatically.
|
- Pass `-o json` for structured output when parsing programmatically — on
|
||||||
|
**entity commands only**. On `tea api`, `-o` is a *file name*: `-o json`
|
||||||
|
writes the response body to a file called `json` and leaves stdout empty.
|
||||||
|
The response is already JSON, so there is nothing to format; use `-` for
|
||||||
|
stdout, or leave the flag off.
|
||||||
- Use `--fields, -f` to narrow columns.
|
- Use `--fields, -f` to narrow columns.
|
||||||
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
||||||
- If a `tea` command is blocked by `tea-guard`: either you forgot
|
- If a `tea` command is blocked by `tea-guard`: either you forgot
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ Without args lists PRs; with `<index>` shows PR detail. Fields: `index,state,aut
|
|||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `list, ls` (`--state`)
|
- `list, ls` (`--state`)
|
||||||
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing.
|
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing. Needs a checkout, same as `create`: `--repo` is a path here, not a slug.
|
||||||
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash.
|
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash. Needs a checkout, same as `create`.
|
||||||
- `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`).
|
- `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`).
|
||||||
|
**Needs a local checkout.** `--repo owner/repo` is *not* accepted here — the
|
||||||
|
slug fails with `local repository required: execute from a repo dir, or
|
||||||
|
specify a path with --repo`, whose advice reads like the flag was missing.
|
||||||
|
Run it with cwd inside the checkout and no `--repo`, or pass `--repo
|
||||||
|
/path/to/checkout`. From a git worktree, point `--repo` at the main
|
||||||
|
checkout. With no checkout in reach, `POST repos/{owner}/{repo}/pulls`
|
||||||
|
through `tea api`, which takes the slug.
|
||||||
- `close <idx>...`, `reopen, open <idx>...`
|
- `close <idx>...`, `reopen, open <idx>...`
|
||||||
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
|
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
|
||||||
- `review <idx>` — interactive review.
|
- `review <idx>` — interactive review.
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ Authenticated HTTP request to the Gitea API. Endpoints are auto-prefixed with `/
|
|||||||
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
|
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
|
||||||
- `--header/-H key:value` (repeatable)
|
- `--header/-H key:value` (repeatable)
|
||||||
- `--include/-i` — write status + response headers to stderr.
|
- `--include/-i` — write status + response headers to stderr.
|
||||||
- `--output/-o <file>` — write response body to file (`-` = stdout).
|
- `--output/-o <file>` — write response body to file (`-` = stdout). **Not the entity commands' format flag**: `-o json` here creates a file named `json` and prints nothing. The body is already JSON.
|
||||||
- Quote the endpoint if it contains `?` or `&` to prevent shell expansion.
|
- Quote the endpoint if it contains `?` or `&` to prevent shell expansion.
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ Version: `tea 0.14.1` (go-sdk v0.25.1). Source: recursive `--help` traversal. Up
|
|||||||
| Flag | Purpose |
|
| Flag | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--login, -l <name>` | use a specific login from the config |
|
| `--login, -l <name>` | use a specific login from the config |
|
||||||
| `--repo, -r <owner/repo>` | override repository context (local path or slug) |
|
| `--repo, -r <owner/repo>` | override repository context (local path or slug). **A slug only works where the command is pure API.** `pulls create`, `pulls checkout` and `pulls clean` need a real checkout and read this flag as a path — see [SKILL.md](../../SKILL.md) |
|
||||||
| `--remote, -R <name>` | discover login from this git remote |
|
| `--remote, -R <name>` | discover login from this git remote |
|
||||||
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json` |
|
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json`. **Entity commands only** — on `tea api` the same flag is a FILE NAME, see [HELPERS](./helpers.md) |
|
||||||
| `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) |
|
| `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) |
|
||||||
| `--fields, -f <list>` | which columns to print |
|
| `--fields, -f <list>` | which columns to print |
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
#!/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 + "/")
|
|
||||||
@@ -255,6 +255,11 @@ class FakeGitea(object):
|
|||||||
path, _, query = endpoint.partition("?")
|
path, _, query = endpoint.partition("?")
|
||||||
params = dict(urllib.parse.parse_qsl(query))
|
params = dict(urllib.parse.parse_qsl(query))
|
||||||
|
|
||||||
|
# Every pull asks for an issue's native links now (dependencies are the
|
||||||
|
# default). Nothing here has any; the answer just has to exist.
|
||||||
|
if path.endswith("/dependencies"):
|
||||||
|
return []
|
||||||
|
|
||||||
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
|
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
|
||||||
if m and method == "GET":
|
if m and method == "GET":
|
||||||
return self.issues.get(int(m.group(1)))
|
return self.issues.get(int(m.group(1)))
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
close.py — the state changes in Gitea, and the local file follows it or nothing
|
||||||
|
happens at all.
|
||||||
|
|
||||||
|
Two halves, and the second is the one that matters:
|
||||||
|
|
||||||
|
1. **It closes.** A slug, a number, several of either in one run, and
|
||||||
|
`--reopen` going the other way. What goes out is a PATCH carrying `state`
|
||||||
|
and nothing else; what comes back is written into `state:` on the local
|
||||||
|
file, and the index is rebuilt so the store's own table agrees.
|
||||||
|
|
||||||
|
2. **It changes nothing local unless the tracker confirmed it.** A `tea` that
|
||||||
|
exited non-zero, an answer with no number, an answer for another issue, an
|
||||||
|
answer that still says `open`, an `origin: local` issue, a `--dry-run`: in
|
||||||
|
every one of those the file on disk is byte for byte what it was. A bug here
|
||||||
|
makes the store lie about the tracker, so each path is asserted on its own.
|
||||||
|
|
||||||
|
The transport is stubbed at `_gitea.api`, as `test_drop_after_push.py` does,
|
||||||
|
with the same deliberate exception: the non-2xx test stubs `_gitea.subprocess`
|
||||||
|
and lets the real `_gitea.api` run, so "tea exited 1" is proved end to end.
|
||||||
|
|
||||||
|
Nothing here touches a network, and nothing here touches the developer's store:
|
||||||
|
every test builds its own in a `tempfile.TemporaryDirectory()`.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
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 close # noqa: E402
|
||||||
|
import issue # noqa: E402
|
||||||
|
import map as gmap # 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
|
||||||
|
|
||||||
|
BODY = """## Summary
|
||||||
|
Прозаическое описание задачи.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
skills/issue/references/format.md
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [x] что-нибудь работает
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTracker(object):
|
||||||
|
"""`tea api` answered from memory, for state writes only.
|
||||||
|
|
||||||
|
It keeps a `state` per number and flips it on a PATCH, which is the whole
|
||||||
|
contract close.py has with the far side."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.states = {} # number -> "open" / "closed"
|
||||||
|
self.raise_on_write = None # an exception instance to raise
|
||||||
|
self.answer_override = None # what a write answers instead
|
||||||
|
|
||||||
|
def payload_of(self, number):
|
||||||
|
return {"number": number, "state": self.states[number],
|
||||||
|
"title": "A thing", "updated_at": "2026-08-11T00:00:00Z",
|
||||||
|
"html_url": "https://git.example/%s/issues/%d" % (REPO, number)}
|
||||||
|
|
||||||
|
def writes(self):
|
||||||
|
return [c for c in self.calls if c[0] != "GET"]
|
||||||
|
|
||||||
|
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 "/issues/" in path and method == "PATCH":
|
||||||
|
number = int(path.rsplit("/", 1)[1])
|
||||||
|
if self.raise_on_write is not None:
|
||||||
|
raise self.raise_on_write
|
||||||
|
self.states.setdefault(number, "open")
|
||||||
|
if "state" in (payload or {}):
|
||||||
|
self.states[number] = payload["state"]
|
||||||
|
if self.answer_override is not None:
|
||||||
|
return self.answer_override
|
||||||
|
return self.payload_of(number)
|
||||||
|
|
||||||
|
if "/issues/" in path and method == "GET":
|
||||||
|
n = int(path.rsplit("/", 1)[1])
|
||||||
|
return self.payload_of(n) if n in self.states else None
|
||||||
|
|
||||||
|
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||||
|
|
||||||
|
|
||||||
|
class StoreTestCase(unittest.TestCase):
|
||||||
|
"""A temp store and a fake tracker."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
tmp = tempfile.TemporaryDirectory(prefix="tea-close-")
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
self.root = tmp.name
|
||||||
|
self.fake = FakeTracker()
|
||||||
|
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)
|
||||||
|
|
||||||
|
# -- fixtures ----------------------------------------------------------
|
||||||
|
|
||||||
|
def synced(self, id="a-thing", number=101, state="open"):
|
||||||
|
"""An issue that is in the tracker and on disk, the way a pull leaves
|
||||||
|
it: `origin: gitea`, a `gitea:` field, and a ledger entry."""
|
||||||
|
key = gmap.remote_key(REPO, number)
|
||||||
|
iss = issue.Issue(id=id, title="A thing", body=BODY, state=state,
|
||||||
|
labels=["type/task"], origin=gmap.ORIGIN,
|
||||||
|
extra={"gitea": key, "url": "https://git.example/x",
|
||||||
|
"synced": "2026-08-10T00:00:00Z"})
|
||||||
|
issue.save(self.root, iss)
|
||||||
|
m = _gitea.load_map(self.root)
|
||||||
|
m[key] = id
|
||||||
|
_gitea.save_map(self.root, m)
|
||||||
|
self.fake.states[number] = state
|
||||||
|
return iss
|
||||||
|
|
||||||
|
def local_only(self, id="local-thing"):
|
||||||
|
"""An issue that has never left this machine."""
|
||||||
|
iss = issue.Issue(id=id, title="Local thing", body=BODY,
|
||||||
|
labels=["type/task"])
|
||||||
|
issue.save(self.root, iss)
|
||||||
|
return iss
|
||||||
|
|
||||||
|
def dropped(self, id="gone-thing", number=205, state="open"):
|
||||||
|
"""Pushed, and its file went with the push: ledger only."""
|
||||||
|
m = _gitea.load_map(self.root)
|
||||||
|
m[gmap.remote_key(REPO, number)] = id
|
||||||
|
_gitea.save_map(self.root, m)
|
||||||
|
self.fake.states[number] = state
|
||||||
|
return number
|
||||||
|
|
||||||
|
# -- runner ------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_close(self, *argv):
|
||||||
|
self.out, self.err = io.StringIO(), io.StringIO()
|
||||||
|
args = ["close.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(self.out), \
|
||||||
|
contextlib.redirect_stderr(self.err):
|
||||||
|
close.main()
|
||||||
|
return self.out.getvalue(), self.err.getvalue()
|
||||||
|
|
||||||
|
# -- assertions --------------------------------------------------------
|
||||||
|
|
||||||
|
def state_on_disk(self, id):
|
||||||
|
return issue.load(self.root, id).state
|
||||||
|
|
||||||
|
def raw(self, id):
|
||||||
|
with open(issue.path_of(self.root, id)) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def assertUnchanged(self, id, before, why=""):
|
||||||
|
self.assertEqual(self.raw(id), before,
|
||||||
|
"%s.md was rewritten%s" % (id, why and " — " + why))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# it closes
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ClosesTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_a_slug_closes_the_issue_it_names(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
out, _ = self.run_close("a-thing")
|
||||||
|
self.assertEqual(self.fake.states[101], "closed")
|
||||||
|
self.assertIn("closed a-thing #101", out)
|
||||||
|
|
||||||
|
def test_the_local_state_follows(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||||
|
|
||||||
|
def test_only_the_state_is_sent(self):
|
||||||
|
"""Closing is not an edit: no title, no body, no labels ride along."""
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_close("a-thing")
|
||||||
|
writes = self.fake.writes()
|
||||||
|
self.assertEqual(len(writes), 1)
|
||||||
|
method, endpoint, payload = writes[0]
|
||||||
|
self.assertEqual((method, endpoint), ("PATCH", "%s/issues/101" % BASE))
|
||||||
|
self.assertEqual(payload, {"state": "closed"})
|
||||||
|
|
||||||
|
def test_a_number_closes_it_too(self):
|
||||||
|
"""The normal case for a pushed issue — the file is long gone."""
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_close("101")
|
||||||
|
self.assertEqual(self.fake.states[101], "closed")
|
||||||
|
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||||
|
|
||||||
|
def test_every_key_form_is_accepted(self):
|
||||||
|
forms = {110: "110", 111: "#111", 112: "%s#112" % REPO,
|
||||||
|
113: "https://git.example/%s/issues/113" % REPO}
|
||||||
|
for n in forms:
|
||||||
|
self.fake.states[n] = "open"
|
||||||
|
for n, arg in forms.items():
|
||||||
|
with self.subTest(arg=arg):
|
||||||
|
self.run_close(arg)
|
||||||
|
self.assertEqual(self.fake.states[n], "closed")
|
||||||
|
|
||||||
|
def test_several_ids_in_one_run(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.synced("b-thing", 102)
|
||||||
|
self.run_close("a-thing", "102")
|
||||||
|
self.assertEqual(self.fake.states, {101: "closed", 102: "closed"})
|
||||||
|
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||||
|
self.assertEqual(self.state_on_disk("b-thing"), "closed")
|
||||||
|
|
||||||
|
def test_the_same_issue_named_twice_is_written_once(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_close("a-thing", "#101")
|
||||||
|
self.assertEqual(len(self.fake.writes()), 1)
|
||||||
|
|
||||||
|
def test_the_index_is_rebuilt(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
out, _ = self.run_close("a-thing")
|
||||||
|
self.assertIn("index:", out)
|
||||||
|
with open(os.path.join(self.root, "INDEX.md")) as f:
|
||||||
|
self.assertIn("closed", f.read())
|
||||||
|
|
||||||
|
def test_the_body_survives_untouched(self):
|
||||||
|
"""One metadata field changes; the prose and the ticks do not."""
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
before = issue.load(self.root, "a-thing").body
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.assertEqual(issue.load(self.root, "a-thing").body, before)
|
||||||
|
|
||||||
|
def test_synced_is_refreshed(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_close("a-thing")
|
||||||
|
iss = issue.load(self.root, "a-thing")
|
||||||
|
self.assertNotEqual(iss.extra.get("synced"), "2026-08-10T00:00:00Z")
|
||||||
|
self.assertEqual(iss.extra.get("remote-updated"), "2026-08-11T00:00:00Z")
|
||||||
|
|
||||||
|
def test_an_issue_whose_file_was_dropped_still_closes(self):
|
||||||
|
"""No local copy at all: the ledger names it, the tracker takes it, and
|
||||||
|
nothing is written locally."""
|
||||||
|
self.dropped("gone-thing", 205)
|
||||||
|
out, _ = self.run_close("gone-thing")
|
||||||
|
self.assertEqual(self.fake.states[205], "closed")
|
||||||
|
self.assertIn("no local copy", out)
|
||||||
|
self.assertNotIn("index:", out)
|
||||||
|
|
||||||
|
def test_a_number_nobody_here_knows_closes_without_a_slug(self):
|
||||||
|
self.fake.states[777] = "open"
|
||||||
|
out, _ = self.run_close("777")
|
||||||
|
self.assertEqual(self.fake.states[777], "closed")
|
||||||
|
self.assertIn("#777", out)
|
||||||
|
|
||||||
|
|
||||||
|
class ReopensTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_reopen_sends_open(self):
|
||||||
|
self.synced("a-thing", 101, state="closed")
|
||||||
|
out, _ = self.run_close("--reopen", "a-thing")
|
||||||
|
self.assertEqual(self.fake.writes()[0][2], {"state": "open"})
|
||||||
|
self.assertIn("reopened a-thing #101", out)
|
||||||
|
|
||||||
|
def test_reopen_writes_the_local_state_back(self):
|
||||||
|
self.synced("a-thing", 101, state="closed")
|
||||||
|
self.run_close("--reopen", "a-thing")
|
||||||
|
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||||
|
|
||||||
|
def test_close_then_reopen_is_a_round_trip(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.run_close("--reopen", "a-thing")
|
||||||
|
self.assertEqual(self.fake.states[101], "open")
|
||||||
|
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# it refuses
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LocalOnlyTest(StoreTestCase):
|
||||||
|
"""An `origin: local` issue is not in the tracker, so it cannot be closed
|
||||||
|
there — and the local field is not quietly edited instead."""
|
||||||
|
|
||||||
|
def test_it_exits(self):
|
||||||
|
self.local_only("local-thing")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("local-thing")
|
||||||
|
|
||||||
|
def test_the_error_names_the_id_and_says_it_is_not_in_the_tracker(self):
|
||||||
|
self.local_only("local-thing")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("local-thing")
|
||||||
|
err = self.err.getvalue()
|
||||||
|
self.assertIn("local-thing", err)
|
||||||
|
self.assertIn("not in the tracker", err)
|
||||||
|
|
||||||
|
def test_nothing_is_sent(self):
|
||||||
|
self.local_only("local-thing")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("local-thing")
|
||||||
|
self.assertEqual(self.fake.calls, [])
|
||||||
|
|
||||||
|
def test_the_file_is_untouched(self):
|
||||||
|
self.local_only("local-thing")
|
||||||
|
before = self.raw("local-thing")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("local-thing")
|
||||||
|
self.assertUnchanged("local-thing", before)
|
||||||
|
|
||||||
|
def test_a_bad_id_stops_the_whole_run_before_anything_is_sent(self):
|
||||||
|
"""Resolution happens up front, so a typo in the second id does not
|
||||||
|
leave the first one closed."""
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("a-thing", "local-thing")
|
||||||
|
self.assertEqual(self.fake.states[101], "open")
|
||||||
|
self.assertEqual(self.fake.calls, [])
|
||||||
|
|
||||||
|
def test_an_unknown_slug_exits(self):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("no-such-thing")
|
||||||
|
self.assertIn("no-such-thing", self.err.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_not_one_request_is_made(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_close("--dry-run", "a-thing")
|
||||||
|
self.assertEqual(self.fake.calls, [])
|
||||||
|
|
||||||
|
def test_the_file_is_untouched(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
before = self.raw("a-thing")
|
||||||
|
self.run_close("--dry-run", "a-thing")
|
||||||
|
self.assertUnchanged("a-thing", before, "--dry-run must write nothing")
|
||||||
|
|
||||||
|
def test_it_says_what_would_be_closed(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.synced("b-thing", 102)
|
||||||
|
out, _ = self.run_close("--dry-run", "a-thing", "102")
|
||||||
|
self.assertIn("would close a-thing #101", out)
|
||||||
|
self.assertIn("would close b-thing #102", out)
|
||||||
|
self.assertIn("2 issue(s) would be closed", out)
|
||||||
|
|
||||||
|
def test_it_says_reopen_under_reopen(self):
|
||||||
|
self.synced("a-thing", 101, state="closed")
|
||||||
|
out, _ = self.run_close("--dry-run", "--reopen", "a-thing")
|
||||||
|
self.assertIn("would reopen a-thing #101", out)
|
||||||
|
self.assertIn("would be reopened", out)
|
||||||
|
|
||||||
|
def test_it_needs_no_login(self):
|
||||||
|
"""A dry run must work before /tea:auth has ever been run."""
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
with mock.patch.object(_gitea, "require_login",
|
||||||
|
lambda: self.fail("dry run asked for a login")):
|
||||||
|
self.run_close("--dry-run", "a-thing")
|
||||||
|
|
||||||
|
def test_a_local_only_issue_is_still_refused(self):
|
||||||
|
self.local_only("local-thing")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("--dry-run", "local-thing")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the tracker said no
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TrackerFailureTest(StoreTestCase):
|
||||||
|
"""The criterion that matters most: a write that was not confirmed leaves
|
||||||
|
the local file exactly as it was."""
|
||||||
|
|
||||||
|
def test_a_non_2xx_answer_leaves_the_file(self):
|
||||||
|
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
|
||||||
|
or a 500 actually takes, and it ends in `die()`."""
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
before = self.raw("a-thing")
|
||||||
|
|
||||||
|
def fake_run(cmd, capture_output=False, text=False):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
returncode=1, stdout="",
|
||||||
|
stderr="422 Unprocessable Entity: issue is blocked")
|
||||||
|
|
||||||
|
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||||
|
mock.patch.object(_gitea, "subprocess",
|
||||||
|
types.SimpleNamespace(run=fake_run)), \
|
||||||
|
self.assertRaises(SystemExit):
|
||||||
|
self.run_close("a-thing")
|
||||||
|
|
||||||
|
self.assertUnchanged("a-thing", before, "tea exited non-zero")
|
||||||
|
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||||
|
|
||||||
|
def test_a_transport_exception_leaves_the_file(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
before = self.raw("a-thing")
|
||||||
|
self.fake.raise_on_write = OSError("tea: command not found")
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.assertUnchanged("a-thing", before, "the transport raised")
|
||||||
|
|
||||||
|
def test_an_answer_without_a_number_leaves_the_file(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
before = self.raw("a-thing")
|
||||||
|
self.fake.answer_override = {"ok": True, "state": "closed"}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.assertUnchanged("a-thing", before)
|
||||||
|
|
||||||
|
def test_an_answer_for_another_issue_leaves_the_file(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
before = self.raw("a-thing")
|
||||||
|
self.fake.answer_override = {"number": 999, "state": "closed"}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.assertUnchanged("a-thing", before)
|
||||||
|
|
||||||
|
def test_an_answer_that_did_not_change_the_state_leaves_the_file(self):
|
||||||
|
"""A 200 that still says `open` is not a close."""
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
before = self.raw("a-thing")
|
||||||
|
self.fake.answer_override = {"number": 101, "state": "open"}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.assertUnchanged("a-thing", before)
|
||||||
|
|
||||||
|
def test_an_empty_answer_leaves_the_file(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
before = self.raw("a-thing")
|
||||||
|
self.fake.answer_override = None
|
||||||
|
real_api = self.fake.api
|
||||||
|
self.fake.api = lambda *a, **kw: (real_api(*a, **kw), None)[1]
|
||||||
|
with mock.patch.object(_gitea, "api", self.fake.api), \
|
||||||
|
self.assertRaises(SystemExit):
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.assertUnchanged("a-thing", before)
|
||||||
|
|
||||||
|
def test_the_error_says_nothing_local_changed(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.fake.answer_override = {"ok": True}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_close("a-thing")
|
||||||
|
self.assertIn("Nothing local was changed", self.err.getvalue())
|
||||||
|
|
||||||
|
def test_a_failure_partway_through_keeps_the_rest(self):
|
||||||
|
"""Two issues, the second one is not confirmed. The first is
|
||||||
|
legitimately closed; the second's file still says open."""
|
||||||
|
self.synced("aaa-thing", 101)
|
||||||
|
self.synced("zzz-thing", 102)
|
||||||
|
before = self.raw("zzz-thing")
|
||||||
|
|
||||||
|
real = self.fake.api
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def once(login, endpoint, method="GET", payload=None, **kw):
|
||||||
|
got = real(login, endpoint, method, payload, **kw)
|
||||||
|
if method != "GET":
|
||||||
|
seen.append(endpoint)
|
||||||
|
return {"nope": True} if len(seen) > 1 else got
|
||||||
|
|
||||||
|
with mock.patch.object(_gitea, "api", once), \
|
||||||
|
self.assertRaises(SystemExit):
|
||||||
|
self.run_close("aaa-thing", "zzz-thing")
|
||||||
|
|
||||||
|
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
|
||||||
|
self.assertUnchanged("zzz-thing", before, "its write was not confirmed")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the pure parts
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConfirmedTest(unittest.TestCase):
|
||||||
|
"""The gate itself. Everything below it rewrites a file."""
|
||||||
|
|
||||||
|
def test_a_matching_close_is_confirmed(self):
|
||||||
|
self.assertTrue(close.confirmed({"number": 42, "state": "closed"}, 42, "closed"))
|
||||||
|
|
||||||
|
def test_a_mismatched_number_is_not(self):
|
||||||
|
self.assertFalse(close.confirmed({"number": 43, "state": "closed"}, 42, "closed"))
|
||||||
|
|
||||||
|
def test_the_wrong_state_is_not(self):
|
||||||
|
self.assertFalse(close.confirmed({"number": 42, "state": "open"}, 42, "closed"))
|
||||||
|
|
||||||
|
def test_a_missing_state_is_not(self):
|
||||||
|
self.assertFalse(close.confirmed({"number": 42}, 42, "closed"))
|
||||||
|
|
||||||
|
def test_none_and_lists_are_not(self):
|
||||||
|
self.assertFalse(close.confirmed(None, 42, "closed"))
|
||||||
|
self.assertFalse(close.confirmed([{"number": 42, "state": "closed"}], 42, "closed"))
|
||||||
|
|
||||||
|
def test_true_is_not_a_number(self):
|
||||||
|
self.assertFalse(close.confirmed({"number": True, "state": "closed"}, 1, "closed"))
|
||||||
|
|
||||||
|
def test_a_string_number_is_not(self):
|
||||||
|
self.assertFalse(close.confirmed({"number": "42", "state": "closed"}, 42, "closed"))
|
||||||
|
|
||||||
|
|
||||||
|
class KeyFormTest(unittest.TestCase):
|
||||||
|
"""A slug and a key are two vocabularies that must not collide."""
|
||||||
|
|
||||||
|
def test_keys_are_keys(self):
|
||||||
|
for k in ("42", "#42", "owner/repo#42",
|
||||||
|
"https://git.example/owner/repo/issues/42"):
|
||||||
|
self.assertTrue(close.looks_like_key(k), k)
|
||||||
|
|
||||||
|
def test_slugs_are_not_keys(self):
|
||||||
|
for s in ("a-thing", "wire-sqlc-appclick", "close-issues-through-a-script"):
|
||||||
|
self.assertFalse(close.looks_like_key(s), s)
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerPairsTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.m = {"%s#7" % REPO: "a-thing", "other/repo#7": "b-thing",
|
||||||
|
"not-a-key": "c-thing"}
|
||||||
|
|
||||||
|
def test_it_filters_by_repo(self):
|
||||||
|
self.assertEqual(close.ledger_pairs(self.m, REPO), [(REPO, 7, "a-thing")])
|
||||||
|
|
||||||
|
def test_without_a_repo_it_keeps_everything_parseable(self):
|
||||||
|
got = close.ledger_pairs(self.m)
|
||||||
|
self.assertEqual(sorted(s for _r, _n, s in got), ["a-thing", "b-thing"])
|
||||||
|
|
||||||
|
def test_an_ambiguous_number_exits(self):
|
||||||
|
pairs = close.ledger_pairs(self.m)
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
close.resolve("7", {}, pairs)
|
||||||
|
|
||||||
|
|
||||||
|
class AmbiguityTest(StoreTestCase):
|
||||||
|
"""Two repos, one number, no --repo: settle it rather than guess."""
|
||||||
|
|
||||||
|
def test_the_error_points_at_repo(self):
|
||||||
|
_gitea.save_map(self.root, {"%s#7" % REPO: "a-thing",
|
||||||
|
"other/repo#7": "b-thing"})
|
||||||
|
err = io.StringIO()
|
||||||
|
args = ["close.py", "--out", self.root, "7"]
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(io.StringIO()), \
|
||||||
|
contextlib.redirect_stderr(err), \
|
||||||
|
self.assertRaises(SystemExit):
|
||||||
|
close.main()
|
||||||
|
self.assertIn("--repo", err.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class RepoOfTheKeyTest(StoreTestCase):
|
||||||
|
"""A key that names its own repo is sent there, not to whatever repo the
|
||||||
|
CWD happens to be — otherwise `#42` closes somebody else's issue."""
|
||||||
|
|
||||||
|
def run_bare(self, *argv):
|
||||||
|
"""No `--repo`, so the ids have to say where they live."""
|
||||||
|
self.out, self.err = io.StringIO(), io.StringIO()
|
||||||
|
args = ["close.py", "--out", self.root] + list(argv)
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(self.out), \
|
||||||
|
contextlib.redirect_stderr(self.err):
|
||||||
|
close.main()
|
||||||
|
return self.out.getvalue(), self.err.getvalue()
|
||||||
|
|
||||||
|
def test_a_foreign_key_goes_to_its_own_repo(self):
|
||||||
|
self.run_bare("other/repo#42")
|
||||||
|
self.assertEqual(self.fake.writes()[0][1], "repos/other/repo/issues/42")
|
||||||
|
|
||||||
|
def test_a_slug_goes_to_the_repo_its_gitea_field_names(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_bare("a-thing")
|
||||||
|
self.assertEqual(self.fake.writes()[0][1], "%s/issues/101" % BASE)
|
||||||
|
|
||||||
|
def test_two_repos_in_one_run_is_a_question_not_a_guess(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_bare("a-thing", "other/repo#42")
|
||||||
|
self.assertIn("one repo", self.err.getvalue())
|
||||||
|
self.assertEqual(self.fake.calls, [])
|
||||||
|
|
||||||
|
def test_an_explicit_repo_settles_it(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
self.run_close("a-thing", "other/repo#42")
|
||||||
|
self.assertEqual({c[1] for c in self.fake.writes()},
|
||||||
|
{"%s/issues/101" % BASE, "%s/issues/42" % BASE})
|
||||||
|
|
||||||
|
|
||||||
|
class NoStoreTest(StoreTestCase):
|
||||||
|
"""A number needs no local file, and a store that is not there is not an
|
||||||
|
error — closing an issue whose copy push dropped is the normal case."""
|
||||||
|
|
||||||
|
def test_a_number_closes_with_no_store_at_all(self):
|
||||||
|
missing = os.path.join(self.root, "nowhere")
|
||||||
|
self.fake.states[303] = "open"
|
||||||
|
args = ["close.py", "--repo", REPO, "--out", missing, "303"]
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(io.StringIO()), \
|
||||||
|
contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
close.main()
|
||||||
|
self.assertEqual(self.fake.states[303], "closed")
|
||||||
|
self.assertFalse(os.path.isdir(missing), "no store was conjured")
|
||||||
|
|
||||||
|
|
||||||
|
class PayloadFileTest(StoreTestCase):
|
||||||
|
"""The request body goes to the transport's own scratchpad.
|
||||||
|
|
||||||
|
Not to a directory this script picks: `close.py` names the payload and
|
||||||
|
nothing else, the way every other caller does. Where PAYLOAD_ROOT lands is
|
||||||
|
_gitea's business, and test_payload_root.py is where that is tested."""
|
||||||
|
|
||||||
|
def test_the_payload_lands_in_the_transports_scratchpad(self):
|
||||||
|
self.synced("a-thing", 101)
|
||||||
|
payloads = os.path.join(self.root, "payload")
|
||||||
|
with mock.patch.object(_gitea, "PAYLOAD_ROOT", payloads), \
|
||||||
|
mock.patch.object(_gitea, "api", REAL_API), \
|
||||||
|
mock.patch.object(
|
||||||
|
_gitea, "subprocess",
|
||||||
|
types.SimpleNamespace(run=lambda cmd, **kw: types.SimpleNamespace(
|
||||||
|
returncode=0, stderr="",
|
||||||
|
stdout=json.dumps({"number": 101, "state": "closed"})))):
|
||||||
|
self.run_close("a-thing")
|
||||||
|
p = os.path.join(payloads, "state-101.json")
|
||||||
|
self.assertTrue(os.path.isfile(p))
|
||||||
|
with open(p) as f:
|
||||||
|
self.assertEqual(json.load(f), {"state": "closed"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,811 @@
|
|||||||
|
#!/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, 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()
|
||||||
|
# PAYLOAD_ROOT is the repo's own tmp/payload, and a test that stubs the
|
||||||
|
# transport one layer down (see the non-2xx case) reaches the real
|
||||||
|
# write. Point it at the fixture: a test writes in its temp directory
|
||||||
|
# and nowhere else.
|
||||||
|
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||||
|
mock.patch.object(_gitea, "PAYLOAD_ROOT",
|
||||||
|
os.path.join(self.root, "payload")),
|
||||||
|
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,570 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Closed issues leave the store, and nothing else does.
|
||||||
|
|
||||||
|
Two halves, and the second one is the one that matters:
|
||||||
|
|
||||||
|
1. **It evicts.** A closed issue whose `origin:` names a tracker is removed from
|
||||||
|
`tmp/issues/` — the issue file and every sidecar under its slug — by one
|
||||||
|
command, and `INDEX.md` is rebuilt so the directory and its table agree.
|
||||||
|
`skills/sync/scripts/evict.py` does the same after refreshing `state:` from
|
||||||
|
Gitea, so an issue closed in the web UI goes without a pull first.
|
||||||
|
|
||||||
|
2. **It evicts nothing else, ever.** `origin: local` is the only copy of the
|
||||||
|
work there is: it stays in every state, including when it is closed and
|
||||||
|
including when it is named on the command line. An open issue stays. A dry
|
||||||
|
run stays. And a tracker call that fails leaves the whole store on disk —
|
||||||
|
every candidate, not just the ones whose answers had not arrived yet.
|
||||||
|
|
||||||
|
A bug in the second half destroys work, so each path is asserted separately and
|
||||||
|
the assertion is always the same — `os.path.isfile`.
|
||||||
|
|
||||||
|
Nothing here touches a network (the sync half stubs `_gitea.api`, and one test
|
||||||
|
stubs `_gitea.subprocess` so a non-zero `tea` is proved end to end) and nothing
|
||||||
|
here touches the developer's store: every test builds its own under
|
||||||
|
`tempfile.TemporaryDirectory()`.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
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 evict # noqa: E402
|
||||||
|
import issue # noqa: E402
|
||||||
|
import issue_evict # noqa: E402
|
||||||
|
import map as gmap # noqa: E402
|
||||||
|
|
||||||
|
REAL_API = _gitea.api
|
||||||
|
|
||||||
|
REPO = "claude-skills/tea"
|
||||||
|
|
||||||
|
BODY = """## Summary
|
||||||
|
Прозаическое описание задачи.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
skills/issue/references/format.md
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [x] сделано
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class StoreTestCase(unittest.TestCase):
|
||||||
|
"""A temp store, and fixtures for the three kinds of file that live in it."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.mkdtemp(prefix="tea-evict-")
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, True)
|
||||||
|
self.numbers = {}
|
||||||
|
|
||||||
|
# -- fixtures ----------------------------------------------------------
|
||||||
|
|
||||||
|
def local(self, id, state="open"):
|
||||||
|
"""An issue that exists nowhere but here."""
|
||||||
|
return self._write(id, state=state, origin=issue.LOCAL)
|
||||||
|
|
||||||
|
def synced(self, id, state="open", number=None):
|
||||||
|
"""A working copy of something the tracker already has."""
|
||||||
|
n = number if number is not None else 100 + len(self.numbers)
|
||||||
|
self.numbers[id] = n
|
||||||
|
return self._write(id, state=state, origin=gmap.ORIGIN,
|
||||||
|
extra={"gitea": gmap.remote_key(REPO, n),
|
||||||
|
"url": "https://git.example/%s/issues/%d" % (REPO, n),
|
||||||
|
"synced": "2026-08-10T00:00:00Z"})
|
||||||
|
|
||||||
|
def _write(self, id, state, origin, extra=None):
|
||||||
|
iss = issue.Issue(id=id, title=id.replace("-", " ").capitalize(),
|
||||||
|
body=BODY, labels=["type/task"], state=state,
|
||||||
|
origin=origin, extra=dict(extra or {}))
|
||||||
|
issue.save(self.root, iss)
|
||||||
|
return iss
|
||||||
|
|
||||||
|
def comments(self, id):
|
||||||
|
p = _gitea.comments_path(self.root, id)
|
||||||
|
with open(p, "w") as f:
|
||||||
|
f.write("## comment 1 — someone — 2026-08-10\n\nтекст\n")
|
||||||
|
return p
|
||||||
|
|
||||||
|
# -- runners -----------------------------------------------------------
|
||||||
|
|
||||||
|
def run_evict(self, *argv):
|
||||||
|
return self._run(issue_evict, "issue_evict.py", argv)
|
||||||
|
|
||||||
|
def run_sync_evict(self, *argv):
|
||||||
|
return self._run(evict, "evict.py", argv)
|
||||||
|
|
||||||
|
def _run(self, mod, name, argv):
|
||||||
|
self.out, self.err = io.StringIO(), io.StringIO()
|
||||||
|
args = [name, "--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 index(self):
|
||||||
|
with open(os.path.join(self.root, "INDEX.md")) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the domain: what belongs to a slug
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SlugFilesTest(StoreTestCase):
|
||||||
|
"""`issue.slug_files` — how the domain removes an issue completely without
|
||||||
|
knowing what a comment thread is."""
|
||||||
|
|
||||||
|
def test_the_issue_file_comes_first(self):
|
||||||
|
self.synced("a-thing")
|
||||||
|
p = self.comments("a-thing")
|
||||||
|
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||||
|
[issue.path_of(self.root, "a-thing"), p])
|
||||||
|
|
||||||
|
def test_an_issue_with_no_sidecars_is_one_file(self):
|
||||||
|
self.synced("a-thing")
|
||||||
|
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||||
|
[issue.path_of(self.root, "a-thing")])
|
||||||
|
|
||||||
|
def test_a_longer_slug_is_not_a_sidecar(self):
|
||||||
|
"""`a-thing-2` is another issue, not a companion of `a-thing`."""
|
||||||
|
self.synced("a-thing")
|
||||||
|
self.synced("a-thing-2")
|
||||||
|
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||||
|
[issue.path_of(self.root, "a-thing")])
|
||||||
|
|
||||||
|
def test_a_missing_store_is_empty_not_an_error(self):
|
||||||
|
self.assertEqual(issue.slug_files(os.path.join(self.root, "nope"), "x"), [])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the domain: it evicts
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EvictsClosedTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_a_closed_synced_issue_goes(self):
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
self.run_evict()
|
||||||
|
self.assertGone("old-thing")
|
||||||
|
|
||||||
|
def test_the_comment_thread_goes_with_it(self):
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
p = self.comments("old-thing")
|
||||||
|
self.run_evict()
|
||||||
|
self.assertFalse(os.path.isfile(p), "the thread outlived the issue")
|
||||||
|
|
||||||
|
def test_the_store_of_open_and_closed_keeps_exactly_the_open_and_the_local(self):
|
||||||
|
"""The acceptance criterion, whole: a store of both kinds, one run, and
|
||||||
|
what is left is the open issues and the local ones."""
|
||||||
|
self.synced("open-synced")
|
||||||
|
self.synced("closed-synced", state="closed")
|
||||||
|
self.local("open-local")
|
||||||
|
self.local("closed-local", state="closed")
|
||||||
|
|
||||||
|
self.run_evict()
|
||||||
|
|
||||||
|
self.assertEqual(issue.all_ids(self.root),
|
||||||
|
["closed-local", "open-local", "open-synced"])
|
||||||
|
|
||||||
|
def test_the_output_names_every_file_removed(self):
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
p = self.comments("old-thing")
|
||||||
|
out, _ = self.run_evict()
|
||||||
|
self.assertIn("evicted", out)
|
||||||
|
self.assertIn(issue.path_of(self.root, "old-thing"), out)
|
||||||
|
self.assertIn(p, out)
|
||||||
|
|
||||||
|
def test_the_index_is_rebuilt_to_match_the_directory(self):
|
||||||
|
"""`INDEX.md` and the directory agree afterwards — nothing to fix up."""
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
self.synced("live-thing")
|
||||||
|
self.run_evict()
|
||||||
|
index = self.index()
|
||||||
|
self.assertIn("live-thing", index)
|
||||||
|
self.assertNotIn("old-thing", index)
|
||||||
|
|
||||||
|
def test_only_the_named_issue_is_evicted(self):
|
||||||
|
self.synced("first-old", state="closed")
|
||||||
|
self.synced("second-old", state="closed")
|
||||||
|
self.run_evict("first-old")
|
||||||
|
self.assertGone("first-old")
|
||||||
|
self.assertOnDisk("second-old", "it was not named")
|
||||||
|
|
||||||
|
def test_the_ledger_is_not_pruned(self):
|
||||||
|
"""`.remote.json` is the number -> slug ledger, not an index over the
|
||||||
|
files: an evicted issue is exactly as findable as a pushed one."""
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
key = gmap.remote_key(REPO, self.numbers["old-thing"])
|
||||||
|
_gitea.save_map(self.root, {key: "old-thing"})
|
||||||
|
self.run_evict()
|
||||||
|
self.assertEqual(_gitea.load_map(self.root), {key: "old-thing"})
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyTest(unittest.TestCase):
|
||||||
|
"""The decision itself, pure. Everything below it deletes a file."""
|
||||||
|
|
||||||
|
def issues(self, **kinds):
|
||||||
|
return {id: issue.Issue(id=id, state=state, origin=origin)
|
||||||
|
for id, (state, origin) in kinds.items()}
|
||||||
|
|
||||||
|
def test_closed_and_synced_is_evicted(self):
|
||||||
|
got = issue_evict.classify(self.issues(a=("closed", "gitea")))
|
||||||
|
self.assertEqual(got, (["a"], [], []))
|
||||||
|
|
||||||
|
def test_closed_and_local_is_protected(self):
|
||||||
|
got = issue_evict.classify(self.issues(a=("closed", issue.LOCAL)))
|
||||||
|
self.assertEqual(got, ([], ["a"], []))
|
||||||
|
|
||||||
|
def test_open_is_left_alone_whatever_its_origin(self):
|
||||||
|
got = issue_evict.classify(self.issues(a=("open", "gitea"),
|
||||||
|
b=("open", issue.LOCAL)))
|
||||||
|
self.assertEqual(got, ([], [], ["a", "b"]))
|
||||||
|
|
||||||
|
def test_naming_a_local_issue_does_not_make_it_evictable(self):
|
||||||
|
got = issue_evict.classify(self.issues(a=("closed", issue.LOCAL)), ["a"])
|
||||||
|
self.assertEqual(got, ([], ["a"], []))
|
||||||
|
|
||||||
|
def test_ids_restrict_the_question(self):
|
||||||
|
got = issue_evict.classify(self.issues(a=("closed", "gitea"),
|
||||||
|
b=("closed", "gitea")), ["b"])
|
||||||
|
self.assertEqual(got, (["b"], [], []))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the domain: it evicts nothing else
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LocalIsNeverEvictedTest(StoreTestCase):
|
||||||
|
"""The criterion that matters most: `origin: local` IS the work."""
|
||||||
|
|
||||||
|
def test_a_closed_local_issue_stays(self):
|
||||||
|
self.local("closed-local", state="closed")
|
||||||
|
self.run_evict()
|
||||||
|
self.assertOnDisk("closed-local", "origin: local is the only copy")
|
||||||
|
|
||||||
|
def test_a_closed_local_issue_named_explicitly_still_stays(self):
|
||||||
|
self.local("closed-local", state="closed")
|
||||||
|
out, _ = self.run_evict("closed-local")
|
||||||
|
self.assertOnDisk("closed-local", "naming it does not make deleting it safe")
|
||||||
|
self.assertIn("kept", out)
|
||||||
|
|
||||||
|
def test_the_receipt_says_why_it_was_kept(self):
|
||||||
|
self.local("closed-local", state="closed")
|
||||||
|
out, _ = self.run_evict()
|
||||||
|
self.assertIn("origin: local", out)
|
||||||
|
self.assertIn("this file IS the issue", out)
|
||||||
|
|
||||||
|
def test_its_sidecars_stay_too(self):
|
||||||
|
self.local("closed-local", state="closed")
|
||||||
|
p = self.comments("closed-local")
|
||||||
|
self.run_evict()
|
||||||
|
self.assertTrue(os.path.isfile(p))
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunTouchesNothingTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_nothing_is_deleted(self):
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
p = self.comments("old-thing")
|
||||||
|
self.run_evict("--dry-run")
|
||||||
|
self.assertOnDisk("old-thing", "--dry-run must not delete")
|
||||||
|
self.assertTrue(os.path.isfile(p))
|
||||||
|
|
||||||
|
def test_it_prints_what_would_go(self):
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
p = self.comments("old-thing")
|
||||||
|
out, _ = self.run_evict("--dry-run")
|
||||||
|
self.assertIn("would evict", out)
|
||||||
|
self.assertIn(issue.path_of(self.root, "old-thing"), out)
|
||||||
|
self.assertIn(p, out)
|
||||||
|
self.assertIn("nothing was touched", out)
|
||||||
|
|
||||||
|
def test_the_index_is_not_written(self):
|
||||||
|
"""`INDEX.md` is a write like any other — a dry run makes none."""
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
self.run_evict("--dry-run")
|
||||||
|
self.assertFalse(os.path.isfile(os.path.join(self.root, "INDEX.md")))
|
||||||
|
|
||||||
|
|
||||||
|
class NoOpRunsWriteNothingTest(StoreTestCase):
|
||||||
|
|
||||||
|
def test_a_store_with_nothing_to_evict_is_not_rewritten(self):
|
||||||
|
self.synced("live-thing")
|
||||||
|
out, _ = self.run_evict()
|
||||||
|
self.assertIn("0 issue(s) evicted", out)
|
||||||
|
self.assertFalse(os.path.isfile(os.path.join(self.root, "INDEX.md")))
|
||||||
|
|
||||||
|
def test_an_unknown_id_stops_the_run(self):
|
||||||
|
self.synced("old-thing", state="closed")
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_evict("no-such-thing")
|
||||||
|
self.assertOnDisk("old-thing", "the run stopped before anything went")
|
||||||
|
|
||||||
|
def test_a_missing_store_is_an_error_and_not_a_directory_to_create(self):
|
||||||
|
missing = os.path.join(self.root, "nope")
|
||||||
|
self.out, self.err = io.StringIO(), io.StringIO()
|
||||||
|
argv = ["issue_evict.py", "--out", missing]
|
||||||
|
with mock.patch.object(sys, "argv", argv), \
|
||||||
|
contextlib.redirect_stdout(self.out), \
|
||||||
|
contextlib.redirect_stderr(self.err), \
|
||||||
|
self.assertRaises(SystemExit):
|
||||||
|
issue_evict.main()
|
||||||
|
self.assertFalse(os.path.isdir(missing))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the bridge: the state comes from the tracker
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FakeTracker(object):
|
||||||
|
"""`tea api` answered from memory. GET on an issue, and nothing else."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.states = {} # number -> "open" | "closed"
|
||||||
|
self.answer_override = {} # number -> whatever it should answer instead
|
||||||
|
self.raise_on = None # number -> exception to raise instead
|
||||||
|
|
||||||
|
def api(self, login, endpoint, method="GET", payload=None, payload_name=None,
|
||||||
|
out_root=None, allow_fail=False):
|
||||||
|
self.calls.append((method, endpoint))
|
||||||
|
number = int(endpoint.rstrip("/").rsplit("/", 1)[1])
|
||||||
|
if self.raise_on == number:
|
||||||
|
raise OSError("tea: command not found")
|
||||||
|
if number in self.answer_override:
|
||||||
|
return self.answer_override[number]
|
||||||
|
return {"number": number, "state": self.states.get(number, "open"),
|
||||||
|
"title": "Whatever", "body": "текст"}
|
||||||
|
|
||||||
|
|
||||||
|
class SyncEvictTestCase(StoreTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
StoreTestCase.setUp(self)
|
||||||
|
self.fake = FakeTracker()
|
||||||
|
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)
|
||||||
|
|
||||||
|
def close_in_gitea(self, id):
|
||||||
|
self.fake.states[self.numbers[id]] = "closed"
|
||||||
|
|
||||||
|
def state_on_disk(self, id):
|
||||||
|
return issue.load(self.root, id).state
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerStateWinsTest(SyncEvictTestCase):
|
||||||
|
|
||||||
|
def test_an_issue_closed_upstream_is_evicted_without_a_pull_first(self):
|
||||||
|
"""The observed workflow, in one command: the file still says `open`."""
|
||||||
|
self.synced("old-thing", state="open")
|
||||||
|
self.close_in_gitea("old-thing")
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertGone("old-thing")
|
||||||
|
|
||||||
|
def test_an_issue_still_open_upstream_stays(self):
|
||||||
|
self.synced("live-thing", state="open")
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertOnDisk("live-thing", "Gitea says it is open")
|
||||||
|
|
||||||
|
def test_a_stale_closed_file_is_corrected_and_kept(self):
|
||||||
|
"""Reopened in the web UI: the local `state:` stops lying, and the file
|
||||||
|
is not evicted on the strength of what it used to say."""
|
||||||
|
self.synced("back-thing", state="closed")
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertOnDisk("back-thing", "Gitea says it is open again")
|
||||||
|
self.assertEqual(self.state_on_disk("back-thing"), "open")
|
||||||
|
|
||||||
|
def test_a_local_issue_is_never_asked_about(self):
|
||||||
|
self.local("closed-local", state="closed")
|
||||||
|
out, _ = self.run_sync_evict()
|
||||||
|
self.assertEqual(self.fake.calls, [])
|
||||||
|
self.assertOnDisk("closed-local")
|
||||||
|
|
||||||
|
def test_an_issue_with_no_handle_is_reported_and_kept(self):
|
||||||
|
"""`origin: gitea` and nothing to reach it by: a guess would delete a
|
||||||
|
file nobody can get back."""
|
||||||
|
issue.save(self.root, issue.Issue(id="orphan-thing", title="Orphan thing",
|
||||||
|
body=BODY, labels=["type/task"],
|
||||||
|
state="closed", origin=gmap.ORIGIN))
|
||||||
|
_, err = self.run_sync_evict()
|
||||||
|
self.assertIn("orphan-thing", err)
|
||||||
|
self.assertOnDisk("orphan-thing", "it could not be verified")
|
||||||
|
|
||||||
|
def test_the_index_matches_the_directory_afterwards(self):
|
||||||
|
self.synced("old-thing", state="open")
|
||||||
|
self.synced("live-thing", state="open")
|
||||||
|
self.close_in_gitea("old-thing")
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertNotIn("old-thing", self.index())
|
||||||
|
self.assertIn("live-thing", self.index())
|
||||||
|
|
||||||
|
def test_dry_run_asks_but_neither_writes_nor_deletes(self):
|
||||||
|
self.synced("old-thing", state="open")
|
||||||
|
self.close_in_gitea("old-thing")
|
||||||
|
out, _ = self.run_sync_evict("--dry-run")
|
||||||
|
self.assertTrue(self.fake.calls, "it should still have asked")
|
||||||
|
self.assertOnDisk("old-thing", "--dry-run must not delete")
|
||||||
|
self.assertEqual(self.state_on_disk("old-thing"), "open",
|
||||||
|
"--dry-run must not write the refreshed state either")
|
||||||
|
self.assertIn("would evict", out)
|
||||||
|
|
||||||
|
|
||||||
|
class SurvivesEveryTrackerFailureTest(SyncEvictTestCase):
|
||||||
|
"""A failed call evicts nothing — including the candidates whose answers had
|
||||||
|
already arrived."""
|
||||||
|
|
||||||
|
def two_closed(self):
|
||||||
|
self.synced("aaa-thing", state="closed", number=11)
|
||||||
|
self.synced("zzz-thing", state="closed", number=12)
|
||||||
|
self.close_in_gitea("aaa-thing")
|
||||||
|
self.close_in_gitea("zzz-thing")
|
||||||
|
|
||||||
|
def test_a_transport_exception_evicts_nothing(self):
|
||||||
|
self.two_closed()
|
||||||
|
self.fake.raise_on = 12
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertOnDisk("aaa-thing", "its answer arrived, but the run failed")
|
||||||
|
self.assertOnDisk("zzz-thing")
|
||||||
|
|
||||||
|
def test_a_non_2xx_answer_evicts_nothing(self):
|
||||||
|
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
|
||||||
|
or a 500 actually takes, and it ends in `die()`."""
|
||||||
|
self.two_closed()
|
||||||
|
|
||||||
|
def fake_run(cmd, capture_output=False, text=False):
|
||||||
|
return types.SimpleNamespace(returncode=1, stdout="",
|
||||||
|
stderr="500 Internal Server Error")
|
||||||
|
|
||||||
|
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||||
|
mock.patch.object(_gitea, "subprocess",
|
||||||
|
types.SimpleNamespace(run=fake_run)), \
|
||||||
|
self.assertRaises(SystemExit):
|
||||||
|
self.run_sync_evict()
|
||||||
|
|
||||||
|
self.assertOnDisk("aaa-thing", "tea exited non-zero")
|
||||||
|
self.assertOnDisk("zzz-thing", "tea exited non-zero")
|
||||||
|
|
||||||
|
def test_an_answer_for_another_issue_evicts_nothing(self):
|
||||||
|
self.two_closed()
|
||||||
|
self.fake.answer_override[12] = {"number": 999, "state": "closed"}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertOnDisk("aaa-thing")
|
||||||
|
self.assertOnDisk("zzz-thing", "the tracker answered for a different issue")
|
||||||
|
|
||||||
|
def test_an_answer_without_a_state_evicts_nothing(self):
|
||||||
|
self.two_closed()
|
||||||
|
self.fake.answer_override[12] = {"number": 12}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertOnDisk("aaa-thing")
|
||||||
|
self.assertOnDisk("zzz-thing")
|
||||||
|
|
||||||
|
def test_an_empty_answer_evicts_nothing(self):
|
||||||
|
"""`tea` exited 0 and printed nothing — api returns None."""
|
||||||
|
self.two_closed()
|
||||||
|
self.fake.answer_override[12] = None
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertOnDisk("aaa-thing")
|
||||||
|
self.assertOnDisk("zzz-thing")
|
||||||
|
|
||||||
|
def test_the_error_says_nothing_was_evicted(self):
|
||||||
|
self.two_closed()
|
||||||
|
self.fake.answer_override[12] = {"ok": True}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertIn("Nothing was evicted", self.err.getvalue())
|
||||||
|
|
||||||
|
def test_no_state_is_written_back_before_the_failure_either(self):
|
||||||
|
"""The write-back happens after every answer is in, so a run that dies
|
||||||
|
leaves the files exactly as it found them."""
|
||||||
|
self.synced("aaa-thing", state="closed", number=11)
|
||||||
|
self.synced("zzz-thing", state="closed", number=12)
|
||||||
|
self.fake.states[11] = "open" # would be corrected on a good run
|
||||||
|
self.fake.answer_override[12] = {"nope": True}
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.run_sync_evict()
|
||||||
|
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmedStateTest(unittest.TestCase):
|
||||||
|
"""The gate itself, in the shape of `push.confirmed_number`."""
|
||||||
|
|
||||||
|
def test_a_matching_answer_is_confirmed(self):
|
||||||
|
self.assertEqual(evict.confirmed_state({"number": 42, "state": "closed"}, 42),
|
||||||
|
"closed")
|
||||||
|
self.assertEqual(evict.confirmed_state({"number": 42, "state": "open"}, 42),
|
||||||
|
"open")
|
||||||
|
|
||||||
|
def test_another_issue_is_not(self):
|
||||||
|
self.assertIsNone(evict.confirmed_state({"number": 43, "state": "closed"}, 42))
|
||||||
|
|
||||||
|
def test_none_is_not(self):
|
||||||
|
self.assertIsNone(evict.confirmed_state(None, 42))
|
||||||
|
|
||||||
|
def test_a_list_is_not(self):
|
||||||
|
self.assertIsNone(evict.confirmed_state([{"number": 42, "state": "closed"}], 42))
|
||||||
|
|
||||||
|
def test_a_missing_state_is_not(self):
|
||||||
|
self.assertIsNone(evict.confirmed_state({"number": 42}, 42))
|
||||||
|
|
||||||
|
def test_an_unknown_state_is_not(self):
|
||||||
|
self.assertIsNone(evict.confirmed_state({"number": 42, "state": "merged"}, 42))
|
||||||
|
|
||||||
|
def test_a_string_number_is_not(self):
|
||||||
|
self.assertIsNone(evict.confirmed_state({"number": "42", "state": "closed"}, 42))
|
||||||
|
|
||||||
|
def test_true_is_not_a_number(self):
|
||||||
|
self.assertIsNone(evict.confirmed_state({"number": True, "state": "closed"}, 1))
|
||||||
|
|
||||||
|
|
||||||
|
class CandidatesTest(StoreTestCase):
|
||||||
|
"""Who the tracker is asked about at all."""
|
||||||
|
|
||||||
|
def test_a_synced_issue_is_asked_about_in_its_own_repo(self):
|
||||||
|
self.synced("a-thing", number=7)
|
||||||
|
checkable, unverifiable = evict.candidates(issue.load_all(self.root))
|
||||||
|
self.assertEqual(checkable, [("a-thing", REPO, 7)])
|
||||||
|
self.assertEqual(unverifiable, [])
|
||||||
|
|
||||||
|
def test_a_local_issue_is_in_neither_list(self):
|
||||||
|
self.local("local-thing", state="closed")
|
||||||
|
self.assertEqual(evict.candidates(issue.load_all(self.root)), ([], []))
|
||||||
|
|
||||||
|
def test_a_handle_that_cannot_be_parsed_is_unverifiable(self):
|
||||||
|
issue.save(self.root, issue.Issue(id="bad-thing", origin=gmap.ORIGIN,
|
||||||
|
extra={"gitea": "not-a-key"}))
|
||||||
|
checkable, unverifiable = evict.candidates(issue.load_all(self.root))
|
||||||
|
self.assertEqual(checkable, [])
|
||||||
|
self.assertEqual([id for id, _ in unverifiable], ["bad-thing"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
What the guard guards: `tea` the command, not `tea` the word.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
The bug these tests hold down: the guard asked whether the string contained
|
||||||
|
`tea` surrounded by whitespace, so in a repository *about* the CLI it blocked
|
||||||
|
prose. An issue title, a commit message quoting a raw call, `grep -rn " tea "`
|
||||||
|
and `echo tea` were all refused, with a message telling the operator to add
|
||||||
|
`--login` to `git commit`. The advice could not be followed — the only way
|
||||||
|
past was to reword the sentence.
|
||||||
|
|
||||||
|
Two lines are held at once here, and neither may move without the other: the
|
||||||
|
four false positives pass, and every shape that really runs the CLI — after
|
||||||
|
`&&`, after a pipe, in a subshell, in a substitution, twice in one line — is
|
||||||
|
still blocked or still rewritten. A test that only proved the first would be
|
||||||
|
satisfied by deleting the guard.
|
||||||
|
|
||||||
|
No network and no `tea` binary: the hook is pure decision-making, so the
|
||||||
|
fixture is a directory with a pin in it and a JSON payload on stdin.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
GUARD = os.path.join(REPO, "hooks", "tea-guard.sh")
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(REPO, "skills", "auth", "scripts"))
|
||||||
|
import pin # noqa: E402
|
||||||
|
|
||||||
|
LOGIN = "fixture/user"
|
||||||
|
|
||||||
|
ALLOW, BLOCK, REWRITE = "allow", "block", "rewrite"
|
||||||
|
|
||||||
|
|
||||||
|
class GuardCase(unittest.TestCase):
|
||||||
|
"""One temp project with one pinned login; the hook run as the harness
|
||||||
|
runs it."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="tea-guard-")
|
||||||
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
path = pin.settings_path(self.root)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(json.dumps({"env": {pin.ENV_KEY: LOGIN}}))
|
||||||
|
|
||||||
|
def run_guard(self, cmd):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("PYTHONPATH", None)
|
||||||
|
env[pin.PROJECT_DIR_ENV] = self.root
|
||||||
|
p = subprocess.run([sys.executable, GUARD],
|
||||||
|
input=json.dumps({"tool_input": {"command": cmd},
|
||||||
|
"cwd": self.root}),
|
||||||
|
cwd=self.root, env=env,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def verdict(self, cmd):
|
||||||
|
p = self.run_guard(cmd)
|
||||||
|
if p.returncode == 2:
|
||||||
|
return BLOCK, p.stderr
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
if not p.stdout.strip():
|
||||||
|
return ALLOW, ""
|
||||||
|
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
||||||
|
return REWRITE, got
|
||||||
|
|
||||||
|
def assertVerdict(self, cmd, expected):
|
||||||
|
kind, detail = self.verdict(cmd)
|
||||||
|
self.assertEqual(kind, expected,
|
||||||
|
"%r → %s (%s)" % (cmd, kind, detail.strip()))
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the four false positives, verbatim from the report
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestProseAboutTheCliRuns(GuardCase):
|
||||||
|
|
||||||
|
def test_an_issue_title_may_name_the_command(self):
|
||||||
|
self.assertVerdict(
|
||||||
|
'python3 skills/issue/scripts/issue_new.py --type bug '
|
||||||
|
'--title "Warn that tea pulls create needs the repo checkout" '
|
||||||
|
'--label comp/use --severity low', ALLOW)
|
||||||
|
|
||||||
|
def test_a_commit_message_may_quote_a_raw_call(self):
|
||||||
|
self.assertVerdict(
|
||||||
|
"git add -A && git commit -F- <<'EOF'\n"
|
||||||
|
"feat: close issues through a script\n"
|
||||||
|
"\n"
|
||||||
|
"Единственным способом сменить state был сырой вызов\n"
|
||||||
|
"tea api -X PATCH ... repos/OWNER/REPO/issues/N\n"
|
||||||
|
"EOF", ALLOW)
|
||||||
|
|
||||||
|
def test_a_one_line_commit_message_may_too(self):
|
||||||
|
self.assertVerdict('git commit -m "route it through tea api"', ALLOW)
|
||||||
|
|
||||||
|
def test_searching_the_repository_for_the_word(self):
|
||||||
|
for cmd in ('grep -rn " tea " docs/',
|
||||||
|
'grep -rn "tea api" skills/',
|
||||||
|
'echo tea'):
|
||||||
|
self.assertVerdict(cmd, ALLOW)
|
||||||
|
|
||||||
|
def test_the_word_as_a_bare_argument_is_still_an_argument(self):
|
||||||
|
"""`echo tea` was the smallest case in the report; these are the same
|
||||||
|
shape with the word in other argument positions."""
|
||||||
|
for cmd in ('ls tea', 'cat notes/tea', 'python3 x.py tea api'):
|
||||||
|
self.assertVerdict(cmd, ALLOW)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# and the real thing is still guarded
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRealInvocationsStayGuarded(GuardCase):
|
||||||
|
|
||||||
|
def test_a_bare_call_without_a_login_is_blocked(self):
|
||||||
|
detail = self.assertVerdict("tea issues list", BLOCK)
|
||||||
|
self.assertIn("--login", detail)
|
||||||
|
|
||||||
|
def test_the_placeholder_is_rewritten_to_the_pin(self):
|
||||||
|
got = self.assertVerdict(
|
||||||
|
'tea issues list --login "$GITEA_LOGIN" --state open', REWRITE)
|
||||||
|
self.assertIn(LOGIN, got)
|
||||||
|
self.assertNotIn("GITEA_LOGIN", got)
|
||||||
|
|
||||||
|
def test_a_login_named_by_hand_is_blocked(self):
|
||||||
|
detail = self.assertVerdict("tea issues list --login somebody", BLOCK)
|
||||||
|
self.assertIn("do not name the login", detail)
|
||||||
|
|
||||||
|
def test_another_variable_is_not_the_placeholder(self):
|
||||||
|
self.assertVerdict('tea issues list --login "$OTHER"', BLOCK)
|
||||||
|
|
||||||
|
def test_compound_commands_are_read_segment_by_segment(self):
|
||||||
|
for cmd in ('cd /tmp && tea issues list',
|
||||||
|
'echo x | tea api -X GET repos/x/y',
|
||||||
|
'( tea issues list )',
|
||||||
|
'cd /tmp; tea issues list',
|
||||||
|
'FOO=1 tea issues list',
|
||||||
|
'sudo tea issues list',
|
||||||
|
'xargs tea issues list'):
|
||||||
|
self.assertVerdict(cmd, BLOCK)
|
||||||
|
|
||||||
|
def test_substitutions_are_read_too(self):
|
||||||
|
for cmd in ('echo $(tea whoami)',
|
||||||
|
'x=$(tea whoami)',
|
||||||
|
'echo `tea whoami`'):
|
||||||
|
self.assertVerdict(cmd, BLOCK)
|
||||||
|
|
||||||
|
def test_a_guarded_call_beside_prose_that_mentions_the_word(self):
|
||||||
|
"""The two halves of the bug in one line: the guard must ignore the
|
||||||
|
argument and still catch the call."""
|
||||||
|
self.assertVerdict(
|
||||||
|
'git commit -m "route it through tea api" && tea issues list',
|
||||||
|
BLOCK)
|
||||||
|
|
||||||
|
def test_an_absolute_path_to_the_binary_is_the_binary(self):
|
||||||
|
self.assertVerdict("/usr/local/bin/tea issues list", BLOCK)
|
||||||
|
|
||||||
|
def test_every_call_in_the_line_is_rewritten(self):
|
||||||
|
"""A half-rewritten line leaves the second call with an unset variable
|
||||||
|
and therefore no login at all."""
|
||||||
|
got = self.assertVerdict(
|
||||||
|
'tea issues list --login "$GITEA_LOGIN" && '
|
||||||
|
'tea pulls list --login "$GITEA_LOGIN"', REWRITE)
|
||||||
|
self.assertEqual(got.count(LOGIN), 2)
|
||||||
|
self.assertNotIn("GITEA_LOGIN", got)
|
||||||
|
|
||||||
|
def test_a_second_unguarded_call_is_not_covered_by_the_first(self):
|
||||||
|
self.assertVerdict(
|
||||||
|
'tea issues list --login "$GITEA_LOGIN" && tea pulls list', BLOCK)
|
||||||
|
|
||||||
|
def test_prose_naming_the_whitelisted_form_does_not_launder_a_call(self):
|
||||||
|
"""`tea logins list` is allowed because it uses no identity. Quoting
|
||||||
|
that phrase must not turn the call beside it into a whitelisted one."""
|
||||||
|
self.assertVerdict(
|
||||||
|
'echo "run tea logins list first" && tea issues list', BLOCK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheWhitelistStillApplies(GuardCase):
|
||||||
|
|
||||||
|
def test_login_enumeration_needs_no_pin(self):
|
||||||
|
for cmd in ("tea logins list", "tea logins ls",
|
||||||
|
"tea --version", "tea --help"):
|
||||||
|
self.assertVerdict(cmd, ALLOW)
|
||||||
|
|
||||||
|
def test_a_whitelisted_call_next_to_a_guarded_one_does_not_excuse_it(self):
|
||||||
|
self.assertVerdict("tea logins list && tea issues list", BLOCK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnparseableLinesFailClosed(GuardCase):
|
||||||
|
"""An unbalanced quote means the shell's reading and ours may differ. The
|
||||||
|
old substring test decides — it over-matches, and over-matching blocks."""
|
||||||
|
|
||||||
|
def test_an_unterminated_quote_around_a_call_still_blocks(self):
|
||||||
|
self.assertVerdict('tea issues list --state "open', BLOCK)
|
||||||
|
|
||||||
|
def test_an_unterminated_quote_with_no_call_is_still_allowed(self):
|
||||||
|
self.assertVerdict('echo "unterminated', ALLOW)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Where the login pin is found, and that a git worktree is not a dead zone.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
Stdlib unittest, no third-party anything, and not one real network call: every
|
||||||
|
run here is against a throwaway repository with a FAKE `tea` first on PATH.
|
||||||
|
|
||||||
|
The bug: the pin was searched for by walking up from CWD only. A worktree is a
|
||||||
|
*sibling* of the main checkout, and `.claude/settings.local.json` is untracked,
|
||||||
|
so it lives in the main checkout and nowhere else — the whole sync layer died
|
||||||
|
inside any worktree with "no login pinned", while `tea` in the same directory
|
||||||
|
worked, because the tea-guard hook had a second, different copy of the search.
|
||||||
|
|
||||||
|
So these tests hold two lines at once: the pin is reachable from a worktree,
|
||||||
|
and the hook and the scripts get their answer from the same function.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||||
|
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||||
|
HOOKS = os.path.join(REPO, "hooks")
|
||||||
|
|
||||||
|
sys.path.insert(0, AUTH_SCRIPTS)
|
||||||
|
import pin # noqa: E402
|
||||||
|
|
||||||
|
HAVE_GIT = shutil.which("git") is not None
|
||||||
|
|
||||||
|
LOGIN = "fixture/user"
|
||||||
|
ENV_KEY = pin.ENV_KEY
|
||||||
|
|
||||||
|
# A `tea` that answers without a network: an empty list for every GET, a
|
||||||
|
# created object for every write. It records its own argv, which is how a test
|
||||||
|
# reads back the login the call actually ran under.
|
||||||
|
FAKE_TEA = '''#!%s
|
||||||
|
import json, os, sys
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
with open(os.environ["TEA_CALL_LOG"], "a") as f:
|
||||||
|
f.write("\\t".join(argv) + "\\n")
|
||||||
|
sys.stdout.write(json.dumps({"id": 1, "number": 101, "name": "created",
|
||||||
|
"html_url": "https://example.invalid/issues/101",
|
||||||
|
"labels": []})
|
||||||
|
if "-X" in argv else "[]")
|
||||||
|
'''
|
||||||
|
|
||||||
|
ISSUE = """\
|
||||||
|
---
|
||||||
|
id: pinned-work
|
||||||
|
state: open
|
||||||
|
labels: [type/task]
|
||||||
|
assignees: []
|
||||||
|
milestone: none
|
||||||
|
depends: []
|
||||||
|
origin: local
|
||||||
|
---
|
||||||
|
# Pinned work
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Issue фикстуры, живёт в сторе worktree.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
none
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
Нужен, чтобы push.py было что отправить.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] проверяемое условие
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def write(path, text):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
|
||||||
|
class Worktree(object):
|
||||||
|
"""A repository with a pin, and a linked worktree beside it.
|
||||||
|
|
||||||
|
Beside, not below: `main/` and `worktrees/feature/` are siblings, which is
|
||||||
|
the entire shape of the bug. The pin is written after the clone is
|
||||||
|
committed and is covered by .gitignore, so it exists in the main checkout
|
||||||
|
only — exactly as `/tea:auth` leaves it."""
|
||||||
|
|
||||||
|
def __init__(self, pinned=LOGIN):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-")
|
||||||
|
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
|
||||||
|
# own cwd would otherwise disagree with the path we handed it.
|
||||||
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
|
self.main = os.path.join(self.root, "main")
|
||||||
|
self.tree = os.path.join(self.root, "worktrees", "feature")
|
||||||
|
self.calls = os.path.join(self.root, "calls.txt")
|
||||||
|
|
||||||
|
skip = shutil.ignore_patterns("__pycache__")
|
||||||
|
for layer in ("auth", "issue", "sync"):
|
||||||
|
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
||||||
|
os.path.join(self.main, "skills", layer, "scripts"),
|
||||||
|
ignore=skip)
|
||||||
|
shutil.copytree(HOOKS, os.path.join(self.main, "hooks"), ignore=skip)
|
||||||
|
write(os.path.join(self.main, ".gitignore"), "tmp/\n.claude/\n")
|
||||||
|
|
||||||
|
self.bin = os.path.join(self.root, "fakebin")
|
||||||
|
os.makedirs(self.bin)
|
||||||
|
tea = os.path.join(self.bin, "tea")
|
||||||
|
write(tea, FAKE_TEA % sys.executable)
|
||||||
|
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
|
self.git("init", cwd=self.main)
|
||||||
|
self.git("add", "-A", cwd=self.main)
|
||||||
|
self.git("commit", "-m", "fixture", cwd=self.main)
|
||||||
|
self.git("worktree", "add", "-b", "feature", self.tree, cwd=self.main)
|
||||||
|
|
||||||
|
if pinned:
|
||||||
|
write(os.path.join(self.main, ".claude", "settings.local.json"),
|
||||||
|
json.dumps({"env": {ENV_KEY: pinned}}))
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def env(self):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
||||||
|
# The start of the search order, cleared: this fixture is about the
|
||||||
|
# steps *after* it, and the developer's own project must not answer.
|
||||||
|
env.pop(pin.PROJECT_DIR_ENV, None)
|
||||||
|
env["PATH"] = self.bin + os.pathsep + env["PATH"]
|
||||||
|
env["TEA_CALL_LOG"] = self.calls
|
||||||
|
env["HOME"] = self.root # keep the developer's git config out
|
||||||
|
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||||
|
env["GIT_CONFIG_GLOBAL"] = os.devnull
|
||||||
|
return env
|
||||||
|
|
||||||
|
def git(self, *args, **kw):
|
||||||
|
cmd = ["git", "-c", "user.email=fixture@example.invalid",
|
||||||
|
"-c", "user.name=fixture", "-c", "commit.gpgsign=false"] + list(args)
|
||||||
|
p = subprocess.run(cmd, cwd=kw.pop("cwd", self.tree), env=self.env(),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise AssertionError("%s failed:\n%s%s" % (" ".join(cmd), p.stdout, p.stderr))
|
||||||
|
return p.stdout.strip()
|
||||||
|
|
||||||
|
def script(self, layer, name):
|
||||||
|
"""A script as the WORKTREE sees it — the copy the operator would run."""
|
||||||
|
return os.path.join(self.tree, "skills", layer, "scripts", name)
|
||||||
|
|
||||||
|
def run(self, script, *args, **kw):
|
||||||
|
p = subprocess.run([sys.executable, script] + list(args),
|
||||||
|
cwd=kw.pop("cwd", self.tree), env=self.env(),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return p.returncode, p.stdout, p.stderr
|
||||||
|
|
||||||
|
def tea_calls(self):
|
||||||
|
if not os.path.isfile(self.calls):
|
||||||
|
return []
|
||||||
|
with open(self.calls) as f:
|
||||||
|
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
|
||||||
|
|
||||||
|
def logins_used(self):
|
||||||
|
return [a[a.index("--login") + 1] for a in self.tea_calls() if "--login" in a]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the search itself
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSearch(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-unit-")
|
||||||
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
|
||||||
|
def path(self, *parts):
|
||||||
|
return os.path.join(self.root, *parts)
|
||||||
|
|
||||||
|
def pin_at(self, root, login=LOGIN):
|
||||||
|
write(os.path.join(root, ".claude", "settings.local.json"),
|
||||||
|
json.dumps({"env": {ENV_KEY: login}}))
|
||||||
|
|
||||||
|
def test_the_parent_chain_is_searched(self):
|
||||||
|
self.pin_at(self.root)
|
||||||
|
os.makedirs(self.path("a", "b"))
|
||||||
|
self.assertEqual(pin.search(self.path("a", "b"))[0], LOGIN)
|
||||||
|
|
||||||
|
def test_no_pin_is_no_pin(self):
|
||||||
|
os.makedirs(self.path("a"))
|
||||||
|
self.assertEqual(pin.search(self.path("a")), (None, None))
|
||||||
|
|
||||||
|
def test_an_unreadable_pin_is_not_a_login(self):
|
||||||
|
write(self.path(".claude", "settings.local.json"), "{ not json")
|
||||||
|
self.assertEqual(pin.search(self.root), (None, None))
|
||||||
|
|
||||||
|
def test_an_empty_pin_is_not_a_login(self):
|
||||||
|
write(self.path(".claude", "settings.local.json"),
|
||||||
|
json.dumps({"env": {ENV_KEY: " "}}))
|
||||||
|
self.assertEqual(pin.search(self.root), (None, None))
|
||||||
|
|
||||||
|
def test_a_git_file_pointing_at_a_worktree_reaches_the_main_checkout(self):
|
||||||
|
"""The hop, built by hand from the two files git writes — no git
|
||||||
|
needed to state what the layout means."""
|
||||||
|
main, tree = self.path("main"), self.path("elsewhere", "feature")
|
||||||
|
gitdir = os.path.join(main, ".git", "worktrees", "feature")
|
||||||
|
os.makedirs(gitdir)
|
||||||
|
os.makedirs(tree)
|
||||||
|
write(os.path.join(gitdir, "commondir"), "../..\n")
|
||||||
|
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
|
||||||
|
self.pin_at(main)
|
||||||
|
|
||||||
|
self.assertEqual(pin.main_worktree(tree), main)
|
||||||
|
login, src = pin.search(tree)
|
||||||
|
self.assertEqual(login, LOGIN)
|
||||||
|
self.assertEqual(src, pin.settings_path(main))
|
||||||
|
|
||||||
|
def test_an_ordinary_clone_is_not_a_worktree(self):
|
||||||
|
os.makedirs(self.path("clone", ".git"))
|
||||||
|
self.assertIsNone(pin.main_worktree(self.path("clone")))
|
||||||
|
|
||||||
|
def test_a_submodule_pointer_is_not_a_worktree(self):
|
||||||
|
"""`.git` is a file there too, but it points into .git/modules/… and
|
||||||
|
the tree it belongs to is already on the parent chain."""
|
||||||
|
sub = self.path("super", "lib")
|
||||||
|
gitdir = self.path("super", ".git", "modules", "lib")
|
||||||
|
os.makedirs(gitdir)
|
||||||
|
os.makedirs(sub)
|
||||||
|
write(os.path.join(sub, ".git"), "gitdir: %s\n" % gitdir)
|
||||||
|
self.assertIsNone(pin.main_worktree(sub))
|
||||||
|
|
||||||
|
def test_the_chain_wins_over_the_hop(self):
|
||||||
|
"""The worktree branch may only find a pin the walk up would have
|
||||||
|
missed entirely — it never overrides a nearer one."""
|
||||||
|
main, tree = self.path("main"), self.path("elsewhere", "feature")
|
||||||
|
gitdir = os.path.join(main, ".git", "worktrees", "feature")
|
||||||
|
os.makedirs(gitdir)
|
||||||
|
os.makedirs(tree)
|
||||||
|
write(os.path.join(gitdir, "commondir"), "../..\n")
|
||||||
|
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
|
||||||
|
self.pin_at(main, "main/login")
|
||||||
|
self.pin_at(tree, "worktree/login")
|
||||||
|
self.assertEqual(pin.search(tree)[0], "worktree/login")
|
||||||
|
|
||||||
|
def test_start_dirs_are_ordered_and_deduplicated(self):
|
||||||
|
with mock.patch.dict(os.environ, {pin.PROJECT_DIR_ENV: self.path("p")}):
|
||||||
|
self.assertEqual(pin.start_dirs(self.path("h")),
|
||||||
|
[self.path("p"), self.path("h"),
|
||||||
|
os.path.abspath(os.getcwd())])
|
||||||
|
with mock.patch.dict(os.environ, {}, clear=True):
|
||||||
|
self.assertEqual(pin.start_dirs(), [os.path.abspath(os.getcwd())])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# a script run from a worktree
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@unittest.skipUnless(HAVE_GIT, "git is not installed")
|
||||||
|
class TestScriptsInAWorktree(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.wt = Worktree()
|
||||||
|
self.addCleanup(self.wt.cleanup)
|
||||||
|
|
||||||
|
def test_a_sync_script_run_from_the_worktree_finds_the_login(self):
|
||||||
|
"""The acceptance criterion, run for real: cwd inside the worktree,
|
||||||
|
the pin in the main checkout, and the call goes out under it."""
|
||||||
|
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||||
|
"--repo", "fixture/repo", "--state", "all")
|
||||||
|
self.assertEqual(rc, 0, "remote.py failed:\n%s%s" % (out, err))
|
||||||
|
self.assertNotIn("no login pinned", err)
|
||||||
|
self.assertEqual(self.wt.logins_used(), [LOGIN])
|
||||||
|
|
||||||
|
def test_it_does_not_pin_a_second_login_in_the_worktree(self):
|
||||||
|
"""Nothing here writes a settings file, and the worktree is the last
|
||||||
|
place one should appear: it is deleted with the worktree."""
|
||||||
|
self.wt.run(self.wt.script("sync", "remote.py"), "--repo", "fixture/repo")
|
||||||
|
self.assertFalse(os.path.exists(pin.settings_path(self.wt.tree)),
|
||||||
|
"a second settings.local.json appeared in the worktree")
|
||||||
|
|
||||||
|
def test_with_no_pin_anywhere_it_still_says_so(self):
|
||||||
|
wt = Worktree(pinned=None)
|
||||||
|
self.addCleanup(wt.cleanup)
|
||||||
|
rc, out, err = wt.run(wt.script("sync", "remote.py"), "--repo", "fixture/repo")
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn("no login pinned", err)
|
||||||
|
self.assertEqual(wt.logins_used(), [])
|
||||||
|
|
||||||
|
def test_the_scripts_own_directory_is_not_a_pin_source(self):
|
||||||
|
"""Run the worktree's script from a directory that is in no pinned
|
||||||
|
tree. The script sits inside a repository that has a pin — and it must
|
||||||
|
still refuse, because the pin belongs to the project being worked on,
|
||||||
|
not to the installation."""
|
||||||
|
outside = os.path.join(self.wt.root, "outside")
|
||||||
|
os.makedirs(outside)
|
||||||
|
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||||
|
"--repo", "fixture/repo", cwd=outside)
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn("no login pinned", err)
|
||||||
|
|
||||||
|
def test_push_from_a_worktree_sends_the_worktree_branch(self):
|
||||||
|
"""`branch:` -> Gitea `ref`. The workaround this fix removes — run the
|
||||||
|
worktree's scripts with cwd in the main checkout — sent the main
|
||||||
|
checkout's branch, which is the one field `branch:` exists for."""
|
||||||
|
write(os.path.join(self.wt.tree, "tmp", "issues", "pinned-work.md"), ISSUE)
|
||||||
|
rc, out, err = self.wt.run(self.wt.script("sync", "push.py"),
|
||||||
|
"pinned-work", "--repo", "fixture/repo")
|
||||||
|
self.assertEqual(rc, 0, "push.py failed:\n%s%s" % (out, err))
|
||||||
|
self.assertIn("created pinned-work #101", out)
|
||||||
|
|
||||||
|
with open(os.path.join(self.wt.tree, "tmp", "payload",
|
||||||
|
"issue-pinned-work.json")) as f:
|
||||||
|
payload = json.load(f)
|
||||||
|
self.assertEqual(payload.get("ref"), "feature")
|
||||||
|
self.assertEqual(self.wt.git("rev-parse", "--abbrev-ref", "HEAD"), "feature")
|
||||||
|
self.assertNotEqual(
|
||||||
|
self.wt.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.wt.main),
|
||||||
|
"feature", "the fixture's two trees are on the same branch")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# one order, one copy of it
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@unittest.skipUnless(HAVE_GIT, "git is not installed")
|
||||||
|
class TestTheHookAndTheScriptsAgree(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.wt = Worktree()
|
||||||
|
self.addCleanup(self.wt.cleanup)
|
||||||
|
|
||||||
|
def guard(self, cwd):
|
||||||
|
"""The hook, as the harness calls it: payload on stdin, decision on
|
||||||
|
stdout."""
|
||||||
|
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
|
||||||
|
"cwd": cwd}
|
||||||
|
p = subprocess.run([sys.executable, os.path.join(self.wt.tree, "hooks",
|
||||||
|
"tea-guard.sh")],
|
||||||
|
input=json.dumps(payload), cwd=cwd, env=self.wt.env(),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def test_the_hook_resolves_the_pin_from_the_worktree_too(self):
|
||||||
|
p = self.guard(self.wt.tree)
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
||||||
|
self.assertIn(LOGIN, got)
|
||||||
|
self.assertNotIn("GITEA_LOGIN", got)
|
||||||
|
|
||||||
|
def test_the_hook_and_a_script_answer_the_same_directory_alike(self):
|
||||||
|
"""The regression that started this: in one directory the hook
|
||||||
|
resolved the login and every script said there was none."""
|
||||||
|
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||||
|
"--repo", "fixture/repo")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
script_login = self.wt.logins_used()[0]
|
||||||
|
hook_login = json.loads(self.guard(self.wt.tree).stdout)[
|
||||||
|
"hookSpecificOutput"]["updatedInput"]["command"].split("--login ")[1].split()[0]
|
||||||
|
self.assertEqual(hook_login, script_login)
|
||||||
|
|
||||||
|
def test_the_hook_still_blocks_when_nothing_is_pinned(self):
|
||||||
|
wt = Worktree(pinned=None)
|
||||||
|
self.addCleanup(wt.cleanup)
|
||||||
|
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
|
||||||
|
"cwd": wt.tree}
|
||||||
|
p = subprocess.run([sys.executable, os.path.join(wt.tree, "hooks", "tea-guard.sh")],
|
||||||
|
input=json.dumps(payload), cwd=wt.tree, env=wt.env(),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
self.assertEqual(p.returncode, 2)
|
||||||
|
self.assertIn("no login is pinned", p.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNobodyKeepsASecondCopy(unittest.TestCase):
|
||||||
|
"""Mechanical: the search order is written in pin.py, and the two callers
|
||||||
|
spell neither the path nor the walk."""
|
||||||
|
|
||||||
|
CALLERS = (os.path.join(HOOKS, "tea-guard.sh"),
|
||||||
|
os.path.join(SYNC_SCRIPTS, "_gitea.py"))
|
||||||
|
|
||||||
|
def source(self, path):
|
||||||
|
with open(path) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_the_path_is_spelled_once(self):
|
||||||
|
self.assertEqual(pin.SETTINGS_PARTS, (".claude", "settings.local.json"))
|
||||||
|
for path in self.CALLERS:
|
||||||
|
body = self.source(path)
|
||||||
|
for literal in ('".claude"', "'.claude'"):
|
||||||
|
self.assertNotIn(literal, body,
|
||||||
|
"%s builds the settings path itself" % path)
|
||||||
|
|
||||||
|
def test_both_callers_go_through_the_module(self):
|
||||||
|
for path in self.CALLERS:
|
||||||
|
self.assertIn("import pin", self.source(path),
|
||||||
|
"%s does not resolve the pin through pin.py" % path)
|
||||||
|
|
||||||
|
def test_the_domain_layer_never_learns_what_a_login_is(self):
|
||||||
|
"""The layer rule, unchanged by this: the identity module is imported
|
||||||
|
by the bridge and by the hook, never by a domain."""
|
||||||
|
for layer in ("issue", "page"):
|
||||||
|
d = os.path.join(REPO, "skills", layer, "scripts")
|
||||||
|
for name in sorted(os.listdir(d)):
|
||||||
|
if not name.endswith(".py"):
|
||||||
|
continue
|
||||||
|
body = self.source(os.path.join(d, name))
|
||||||
|
for banned in ("import pin", "GITEA_LOGIN", "settings.local.json"):
|
||||||
|
self.assertNotIn(banned, body, "%s/%s: %s" % (layer, name, banned))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
#!/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,249 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Where request bodies land, and that writing one never conjures a store.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
Stdlib unittest, no third-party anything. The bug these tests pin down:
|
||||||
|
`labels.py --bootstrap` on a fresh checkout left `tmp/issues/.payload/` behind,
|
||||||
|
because the only place `_gitea.api` had to put a request file was whatever root
|
||||||
|
the caller handed it — and the label bootstrap, which touches no issue at all,
|
||||||
|
handed it the issue store. A store materialized as a side effect of an
|
||||||
|
operation that has nothing to do with issues.
|
||||||
|
|
||||||
|
Every run here is against a throwaway repository with a FAKE `tea` first on
|
||||||
|
PATH, so nothing reaches the network and the developer's own store is never in
|
||||||
|
the blast radius.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
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")
|
||||||
|
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||||
|
|
||||||
|
sys.path.insert(0, SYNC_SCRIPTS)
|
||||||
|
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||||
|
import _gitea # noqa: E402
|
||||||
|
import issue # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# A `tea` that answers without a network: an empty list for every GET (so the
|
||||||
|
# repository looks like it has no labels yet) and a created object for every
|
||||||
|
# write. It also records its own argv, which is how a test can tell that the
|
||||||
|
# payload file the script wrote is the one the call actually referenced.
|
||||||
|
FAKE_TEA = '''#!%s
|
||||||
|
import json, os, sys
|
||||||
|
with open(os.path.join(os.environ["TEA_CALL_LOG"], "calls.txt"), "a") as f:
|
||||||
|
f.write("\\t".join(sys.argv[1:]) + "\\n")
|
||||||
|
sys.stdout.write(json.dumps({"id": 1, "name": "created"})
|
||||||
|
if "-X" in sys.argv else "[]")
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepo(object):
|
||||||
|
"""A self-contained repository with no store and no tmp/ at all."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
# realpath: on macOS $TMPDIR is a symlink, and a child 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)
|
||||||
|
# the transport resolves the login pin through skills/auth/scripts
|
||||||
|
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
|
||||||
|
os.makedirs(self.path("sub", "deeper"))
|
||||||
|
|
||||||
|
# the login pin the transport insists on, local to this fixture
|
||||||
|
os.makedirs(self.path(".claude"))
|
||||||
|
with open(self.path(".claude", "settings.local.json"), "w") as f:
|
||||||
|
json.dump({"env": {"GITEA_LOGIN": "fixture/user"}}, f)
|
||||||
|
|
||||||
|
self.bin = self.path("fakebin")
|
||||||
|
os.makedirs(self.bin)
|
||||||
|
tea = os.path.join(self.bin, "tea")
|
||||||
|
with open(tea, "w") as f:
|
||||||
|
f.write(FAKE_TEA % sys.executable)
|
||||||
|
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payloads(self):
|
||||||
|
return self.path("tmp", "payload")
|
||||||
|
|
||||||
|
def script(self, layer, name):
|
||||||
|
return self.path("skills", layer, "scripts", name)
|
||||||
|
|
||||||
|
def run(self, script, *args, **kw):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
||||||
|
env["PATH"] = self.bin + os.pathsep + env["PATH"]
|
||||||
|
env["TEA_CALL_LOG"] = self.root
|
||||||
|
p = subprocess.run([sys.executable, script] + list(args),
|
||||||
|
cwd=kw.pop("cwd", self.root), env=env,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return p.returncode, p.stdout, p.stderr
|
||||||
|
|
||||||
|
def calls(self):
|
||||||
|
p = os.path.join(self.root, "calls.txt")
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
return []
|
||||||
|
with open(p) as f:
|
||||||
|
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# resolution
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPayloadRoot(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_root_is_absolute_and_repo_anchored(self):
|
||||||
|
self.assertTrue(os.path.isabs(_gitea.PAYLOAD_ROOT), _gitea.PAYLOAD_ROOT)
|
||||||
|
self.assertEqual(_gitea.PAYLOAD_ROOT, os.path.join(REPO, "tmp", "payload"))
|
||||||
|
|
||||||
|
def test_it_is_not_the_issue_store_and_not_inside_one(self):
|
||||||
|
"""The acceptance criterion, as a path fact: a request body is not
|
||||||
|
store content, so it may not live in a store or under one."""
|
||||||
|
self.assertNotEqual(_gitea.PAYLOAD_ROOT, issue.ISSUE_ROOT)
|
||||||
|
self.assertFalse(_gitea.PAYLOAD_ROOT.startswith(issue.ISSUE_ROOT + os.sep))
|
||||||
|
self.assertFalse(issue.ISSUE_ROOT.startswith(_gitea.PAYLOAD_ROOT + os.sep))
|
||||||
|
|
||||||
|
def test_the_name_says_what_it_holds(self):
|
||||||
|
"""Named so the distinction is visible: a top-level directory called
|
||||||
|
`payload`, not a dotdir hiding among an issue's files."""
|
||||||
|
self.assertEqual(os.path.basename(_gitea.PAYLOAD_ROOT), "payload")
|
||||||
|
self.assertFalse(os.path.basename(_gitea.PAYLOAD_ROOT).startswith("."))
|
||||||
|
|
||||||
|
def test_gitignore_covers_it(self):
|
||||||
|
with open(os.path.join(REPO, ".gitignore")) as f:
|
||||||
|
ignored = {line.strip() for line in f}
|
||||||
|
self.assertEqual(_gitea.PAYLOAD_PARTS[0], "tmp")
|
||||||
|
self.assertIn("tmp/", ignored,
|
||||||
|
"the payload directory is not covered by .gitignore")
|
||||||
|
|
||||||
|
def test_resolution_is_anchored_on_the_module_not_on_cwd(self):
|
||||||
|
repo = FakeRepo()
|
||||||
|
self.addCleanup(repo.cleanup)
|
||||||
|
self.assertEqual(_gitea.payload_root(repo.path("sub", "deeper")),
|
||||||
|
repo.payloads)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the bug: a label bootstrap that materialized the store
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLabelsTouchesNoStore(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.repo = FakeRepo()
|
||||||
|
self.addCleanup(self.repo.cleanup)
|
||||||
|
|
||||||
|
def bootstrap(self, *args, **kw):
|
||||||
|
rc, out, err = self.repo.run(self.repo.script("sync", "labels.py"),
|
||||||
|
"--repo", "fixture/repo", *args, **kw)
|
||||||
|
self.assertEqual(rc, 0, "labels.py failed:\n%s%s" % (out, err))
|
||||||
|
return out, err
|
||||||
|
|
||||||
|
def test_bootstrap_creates_no_store(self):
|
||||||
|
"""The reproduction from the report, run for real: no tmp/issues, and
|
||||||
|
no complaint about one either."""
|
||||||
|
out, _ = self.bootstrap()
|
||||||
|
self.assertIn("created", out)
|
||||||
|
self.assertFalse(os.path.exists(self.repo.store),
|
||||||
|
"labels.py created the issue store")
|
||||||
|
|
||||||
|
def test_bootstrap_writes_its_payloads_to_the_payload_root(self):
|
||||||
|
self.bootstrap()
|
||||||
|
self.assertTrue(os.path.isdir(self.repo.payloads),
|
||||||
|
"no payload directory: %s" % self.repo.payloads)
|
||||||
|
written = os.listdir(self.repo.payloads)
|
||||||
|
self.assertIn("label-type-bug.json", written)
|
||||||
|
for name in written:
|
||||||
|
self.assertTrue(name.startswith("label-"), name)
|
||||||
|
|
||||||
|
# and the file named on the command line is the one that was written
|
||||||
|
sent = [a[a.index("-d") + 1][1:] for a in self.repo.calls() if "-d" in a]
|
||||||
|
self.assertTrue(sent)
|
||||||
|
for path in sent:
|
||||||
|
self.assertEqual(os.path.dirname(path), self.repo.payloads)
|
||||||
|
self.assertTrue(os.path.isfile(path), path)
|
||||||
|
|
||||||
|
def test_the_payload_is_the_request_body(self):
|
||||||
|
self.bootstrap()
|
||||||
|
with open(os.path.join(self.repo.payloads, "label-type-bug.json")) as f:
|
||||||
|
body = json.load(f)
|
||||||
|
self.assertEqual(body.get("name"), "type/bug")
|
||||||
|
self.assertTrue(body.get("color"))
|
||||||
|
|
||||||
|
def test_a_dry_run_writes_nothing_at_all(self):
|
||||||
|
out, _ = self.bootstrap("--dry-run")
|
||||||
|
self.assertIn("nothing was written", out)
|
||||||
|
self.assertFalse(os.path.exists(self.repo.path("tmp")),
|
||||||
|
"a dry run left something behind in tmp/")
|
||||||
|
|
||||||
|
def test_the_directory_does_not_follow_cwd(self):
|
||||||
|
"""Run from a subdirectory: still one payload root, at the repo root.
|
||||||
|
A cwd-relative directory is how the store ended up with a second copy
|
||||||
|
of itself, and this one is resolved the same way to avoid the same
|
||||||
|
class of bug."""
|
||||||
|
self.bootstrap(cwd=self.repo.path("sub", "deeper"))
|
||||||
|
self.assertTrue(os.path.isdir(self.repo.payloads))
|
||||||
|
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")))
|
||||||
|
self.assertFalse(os.path.exists(self.repo.store))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# one place, every caller
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestOnePlaceForEveryCaller(unittest.TestCase):
|
||||||
|
|
||||||
|
def hits(self, needle, skip_transport=False):
|
||||||
|
"""Every `layer/script.py:line` mentioning `needle`."""
|
||||||
|
out = []
|
||||||
|
d = SYNC_SCRIPTS
|
||||||
|
layer = os.path.basename(os.path.dirname(d))
|
||||||
|
for name in sorted(os.listdir(d)):
|
||||||
|
if not name.endswith(".py") or (skip_transport and name == "_gitea.py"):
|
||||||
|
continue
|
||||||
|
with open(os.path.join(d, name)) as f:
|
||||||
|
for n, line in enumerate(f, 1):
|
||||||
|
if needle in line:
|
||||||
|
out.append("%s/%s:%d" % (layer, name, n))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def test_no_caller_chooses_where_its_payload_goes(self):
|
||||||
|
"""Whatever the answer is, it has to be the same for all of them —
|
||||||
|
payload files scattered across the stores of whichever command wrote
|
||||||
|
them is the state this replaced."""
|
||||||
|
self.assertEqual(self.hits("out_root"), [],
|
||||||
|
"a caller still picks a payload directory of its own")
|
||||||
|
|
||||||
|
def test_only_the_transport_names_the_directory(self):
|
||||||
|
self.assertEqual(self.hits("PAYLOAD", skip_transport=True), [],
|
||||||
|
"the payload directory is named outside the transport")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
A pull returns the unit of work: the issue AND what blocks it.
|
||||||
|
|
||||||
|
`--deps` used to be opt-in, so `pull.py 42` wrote a file with an empty
|
||||||
|
`depends:` and `issue_tree.py` drew it as a root with no blockers. The edge was
|
||||||
|
not lost — it lives in Gitea's native dependency graph — but it was not asked
|
||||||
|
for, and the body cannot supply it: `map.from_api` writes slugs into the
|
||||||
|
`## Depends on` prose and never `#N`. Following the graph is now the default.
|
||||||
|
|
||||||
|
What is asserted here:
|
||||||
|
|
||||||
|
1. **The default fills the graph.** A bare `pull.py <n>` fills `depends:` and
|
||||||
|
pulls the blocker too, down to `--depth`.
|
||||||
|
2. **`--no-deps` is the way out, and it is free.** No `depends:`, no recursion,
|
||||||
|
and not one request beyond the issue itself.
|
||||||
|
3. **`--deps` still works and means nothing.** Calls written against the old
|
||||||
|
default keep running and get what they always got.
|
||||||
|
4. **The cost is one request per stored issue.** The native links are fetched
|
||||||
|
once and used twice — for `depends:` and for the walk. Never twice.
|
||||||
|
5. **Filter mode follows blockers out of the selection, deliberately.** A
|
||||||
|
blocker no filter selected still lands in the store and does not spend
|
||||||
|
`--limit`; a closed one is dropped like any other closed issue, and so is
|
||||||
|
the edge to it. An issue the filter dropped costs no link request at all.
|
||||||
|
|
||||||
|
The transport is stubbed at `_gitea.api`, as the other suites do it, and the
|
||||||
|
stub records every call so "how many requests" is an observation. No network,
|
||||||
|
and no test touches the developer's store: each builds its own in a
|
||||||
|
`tempfile.TemporaryDirectory()`.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
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 pull # noqa: E402
|
||||||
|
|
||||||
|
REPO = "claude-skills/tea"
|
||||||
|
BASE = "repos/%s" % REPO
|
||||||
|
|
||||||
|
BODY = """## Summary
|
||||||
|
Прозаическое описание задачи.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
skills/issue/references/format.md
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] что-нибудь работает
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def payload(number, title, state="open"):
|
||||||
|
return {"number": number, "title": title, "body": BODY, "state": state,
|
||||||
|
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
|
||||||
|
"milestone": None, "ref": "main", "updated_at": "2026-08-10T00:00:00Z",
|
||||||
|
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||||
|
"repository": {"full_name": REPO}}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTracker(object):
|
||||||
|
"""`tea api` answered from memory, with a native dependency graph.
|
||||||
|
|
||||||
|
`listed` is what the list endpoint serves — the filter's selection. `extra`
|
||||||
|
exists and is fetchable by number but is in no selection, which is how a
|
||||||
|
blocker outside the filter is modelled. `deps` maps a blocked issue's number
|
||||||
|
to the numbers that block it, the direction `GET …/dependencies` reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, listed=(), extra=(), deps=None):
|
||||||
|
self.listed = list(listed)
|
||||||
|
self.issues = {p["number"]: p for p in list(listed) + list(extra)}
|
||||||
|
self.deps = {int(k): list(v) for k, v in (deps or {}).items()}
|
||||||
|
self.calls = [] # (method, path), in request order
|
||||||
|
|
||||||
|
# -- what the tests read off it ----------------------------------------
|
||||||
|
|
||||||
|
def paths(self, suffix):
|
||||||
|
return [p for m, p in self.calls if p.endswith(suffix)]
|
||||||
|
|
||||||
|
def issue_gets(self):
|
||||||
|
"""`GET …/issues/<n>` — one issue fetched by number."""
|
||||||
|
return [p for m, p in self.calls
|
||||||
|
if m == "GET" and p.startswith("%s/issues/" % BASE)
|
||||||
|
and p.rsplit("/", 1)[1].isdigit()]
|
||||||
|
|
||||||
|
# -- the seam ----------------------------------------------------------
|
||||||
|
|
||||||
|
def api(self, login, endpoint, method="GET", payload=None,
|
||||||
|
payload_name=None, out_root=None, allow_fail=False):
|
||||||
|
path, _, qs = endpoint.partition("?")
|
||||||
|
q = urllib.parse.parse_qs(qs)
|
||||||
|
self.calls.append((method, path))
|
||||||
|
|
||||||
|
if path == "%s/issues" % BASE and method == "GET":
|
||||||
|
page, per = int(q["page"][0]), int(q["limit"][0])
|
||||||
|
return self.listed[(page - 1) * per:(page - 1) * per + per]
|
||||||
|
|
||||||
|
if path.endswith("/comments"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if path.endswith("/dependencies") and method == "GET":
|
||||||
|
n = int(path.split("/issues/")[1].split("/")[0])
|
||||||
|
return [self.issues[b] for b in self.deps.get(n, []) if b in self.issues]
|
||||||
|
|
||||||
|
if path.startswith("%s/issues/" % BASE) and method == "GET":
|
||||||
|
return self.issues.get(int(path.rsplit("/", 1)[1]))
|
||||||
|
|
||||||
|
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||||
|
|
||||||
|
|
||||||
|
class PullDepsTestCase(unittest.TestCase):
|
||||||
|
"""A temp store, a fake tracker, no git and no network."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory(prefix="tea-deps-")
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = os.path.join(self.tmp.name, "tmp", "issues")
|
||||||
|
os.makedirs(self.root)
|
||||||
|
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
|
||||||
|
def serve(self, listed=(), extra=(), deps=None):
|
||||||
|
self.fake = FakeTracker(listed, extra, deps)
|
||||||
|
p = mock.patch.object(_gitea, "api", self.fake.api)
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
return self.fake
|
||||||
|
|
||||||
|
def blocked_pair(self):
|
||||||
|
"""#10 "Second thing" is blocked by #7 "First thing"."""
|
||||||
|
return self.serve(listed=[payload(10, "Second thing"),
|
||||||
|
payload(7, "First thing")],
|
||||||
|
deps={10: [7]})
|
||||||
|
|
||||||
|
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(self):
|
||||||
|
return sorted(issue.all_ids(self.root))
|
||||||
|
|
||||||
|
def depends_of(self, id):
|
||||||
|
return issue.load(self.root, id).depends
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 1. the default fills the graph
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DepsAreTheDefaultTest(PullDepsTestCase):
|
||||||
|
|
||||||
|
def test_a_bare_pull_fills_depends(self):
|
||||||
|
"""The acceptance criterion, and the whole point: no flag, and the file
|
||||||
|
knows what blocks it."""
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10")
|
||||||
|
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
|
||||||
|
|
||||||
|
def test_a_bare_pull_stores_the_blocker(self):
|
||||||
|
"""`depends:` pointing at a file that is not there would be worse than
|
||||||
|
an empty one — the blocker comes with it."""
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10")
|
||||||
|
self.assertIn("first-thing", self.stored())
|
||||||
|
|
||||||
|
def test_the_walk_is_recursive(self):
|
||||||
|
"""A blocker's blocker is context too, down to --depth (default 3)."""
|
||||||
|
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
|
||||||
|
deps={1: [2], 2: [3], 3: [4], 4: [5]})
|
||||||
|
self.run_pull("1")
|
||||||
|
self.assertEqual(self.stored(), ["thing-1", "thing-2", "thing-3", "thing-4"],
|
||||||
|
"the default depth of 3 was not what was walked")
|
||||||
|
|
||||||
|
def test_depth_bounds_the_walk(self):
|
||||||
|
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
|
||||||
|
deps={1: [2], 2: [3], 3: [4], 4: [5]})
|
||||||
|
self.run_pull("1", "--depth", "1")
|
||||||
|
self.assertEqual(self.stored(), ["thing-1", "thing-2"])
|
||||||
|
|
||||||
|
def test_the_graph_hint_is_printed_when_there_is_a_graph(self):
|
||||||
|
self.blocked_pair()
|
||||||
|
out, _ = self.run_pull("10")
|
||||||
|
self.assertIn("issue_tree.py", out)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 2. --no-deps is the way out, and it is free
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class NoDepsOptsOutTest(PullDepsTestCase):
|
||||||
|
|
||||||
|
def test_no_deps_leaves_depends_empty(self):
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10", "--no-deps")
|
||||||
|
self.assertEqual(self.depends_of("second-thing"), [])
|
||||||
|
|
||||||
|
def test_no_deps_does_not_pull_the_blocker(self):
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10", "--no-deps")
|
||||||
|
self.assertEqual(self.stored(), ["second-thing"])
|
||||||
|
|
||||||
|
def test_no_deps_spends_no_extra_request(self):
|
||||||
|
"""The other half of the criterion: not the links, not the blocker.
|
||||||
|
One issue asked for, one request made."""
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10", "--no-deps")
|
||||||
|
self.assertEqual(self.fake.paths("/dependencies"), [])
|
||||||
|
self.assertEqual(self.fake.issue_gets(), ["%s/issues/10" % BASE])
|
||||||
|
|
||||||
|
def test_no_deps_prints_no_graph_hint(self):
|
||||||
|
self.blocked_pair()
|
||||||
|
out, _ = self.run_pull("10", "--no-deps")
|
||||||
|
self.assertNotIn("issue_tree.py", out)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 3. --deps is still accepted, and means nothing
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DepsFlagIsANoOpTest(PullDepsTestCase):
|
||||||
|
|
||||||
|
def test_the_flag_is_still_accepted(self):
|
||||||
|
"""Existing calls and the /tea:sync command tables must not break."""
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10", "--deps")
|
||||||
|
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
|
||||||
|
|
||||||
|
def test_it_changes_nothing_about_the_run(self):
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10", "--deps")
|
||||||
|
with_flag = (self.stored(), self.depends_of("second-thing"),
|
||||||
|
list(self.fake.calls))
|
||||||
|
|
||||||
|
self.setUp()
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10")
|
||||||
|
self.assertEqual((self.stored(), self.depends_of("second-thing"),
|
||||||
|
list(self.fake.calls)), with_flag)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 4. one request per stored issue
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TheCostIsOneRequestPerIssueTest(PullDepsTestCase):
|
||||||
|
|
||||||
|
def test_the_links_are_fetched_once_per_issue(self):
|
||||||
|
"""They fill `depends:` AND steer the walk; fetching them twice is
|
||||||
|
double the price the docstring quotes."""
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10")
|
||||||
|
self.assertEqual(self.fake.paths("/dependencies"),
|
||||||
|
["%s/issues/10/dependencies" % BASE,
|
||||||
|
"%s/issues/7/dependencies" % BASE])
|
||||||
|
|
||||||
|
def test_a_bulk_pull_costs_one_per_issue(self):
|
||||||
|
"""The number the docstring quotes: one list request, then one link
|
||||||
|
request per issue that lands in the store."""
|
||||||
|
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 21)])
|
||||||
|
self.run_pull("-q", "x")
|
||||||
|
self.assertEqual(len(self.fake.paths("/dependencies")), 20)
|
||||||
|
self.assertEqual(len(self.fake.paths("/issues")), 1)
|
||||||
|
|
||||||
|
def test_a_cached_issue_costs_its_links_and_nothing_else(self):
|
||||||
|
"""--cached stops the body and the thread, not the graph: a cached
|
||||||
|
issue's blockers can be missing from disk even when it is not."""
|
||||||
|
self.blocked_pair()
|
||||||
|
self.run_pull("10", "--no-deps") # only #10 on disk
|
||||||
|
self.fake.calls = []
|
||||||
|
self.run_pull("10", "--cached")
|
||||||
|
self.assertEqual(self.fake.paths("/dependencies"),
|
||||||
|
["%s/issues/10/dependencies" % BASE,
|
||||||
|
"%s/issues/7/dependencies" % BASE])
|
||||||
|
self.assertIn("first-thing", self.stored())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 5. filter mode follows blockers out of the selection
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FilterModeFollowsOutwardTest(PullDepsTestCase):
|
||||||
|
|
||||||
|
def test_a_blocker_outside_the_filter_lands_in_the_store(self):
|
||||||
|
"""Documented as deliberate: a blocker is followed because a stored
|
||||||
|
issue named it, not because the filter selected it."""
|
||||||
|
self.serve(listed=[payload(1, "Selected thing")],
|
||||||
|
extra=[payload(99, "Outside thing")],
|
||||||
|
deps={1: [99]})
|
||||||
|
self.run_pull("-q", "x")
|
||||||
|
self.assertEqual(self.stored(), ["outside-thing", "selected-thing"])
|
||||||
|
self.assertEqual(self.depends_of("selected-thing"), ["outside-thing"])
|
||||||
|
|
||||||
|
def test_a_blocker_does_not_spend_the_limit(self):
|
||||||
|
"""--limit counts the selection's writes; the graph is not part of the
|
||||||
|
selection, so the store can legitimately hold more than N."""
|
||||||
|
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 5)],
|
||||||
|
extra=[payload(100 + n, "Blocker %d" % n) for n in range(1, 5)],
|
||||||
|
deps={n: [100 + n] for n in range(1, 5)})
|
||||||
|
self.run_pull("-q", "x", "--limit", "2")
|
||||||
|
self.assertEqual(self.stored(),
|
||||||
|
["blocker-1", "blocker-2", "thing-1", "thing-2"])
|
||||||
|
|
||||||
|
def test_a_closed_blocker_is_dropped_with_the_edge_to_it(self):
|
||||||
|
"""The documented exception. Closed is not a unit of work, so filter
|
||||||
|
mode drops it like any other closed issue — and `depends:` must not be
|
||||||
|
left pointing at a file that is not there."""
|
||||||
|
self.serve(listed=[payload(1, "Selected thing")],
|
||||||
|
extra=[payload(99, "Closed blocker", state="closed")],
|
||||||
|
deps={1: [99]})
|
||||||
|
self.run_pull("-q", "x")
|
||||||
|
self.assertEqual(self.stored(), ["selected-thing"])
|
||||||
|
self.assertEqual(self.depends_of("selected-thing"), [])
|
||||||
|
|
||||||
|
def test_a_closed_blocker_is_stored_in_key_mode(self):
|
||||||
|
"""An address is not a bulk read: `pull.py 1` has no closed rule."""
|
||||||
|
self.serve(listed=[payload(1, "Selected thing")],
|
||||||
|
extra=[payload(99, "Closed blocker", state="closed")],
|
||||||
|
deps={1: [99]})
|
||||||
|
self.run_pull("1")
|
||||||
|
self.assertEqual(self.stored(), ["closed-blocker", "selected-thing"])
|
||||||
|
|
||||||
|
def test_a_dropped_closed_issue_costs_no_link_request(self):
|
||||||
|
"""Nothing was stored for it, so there is no unit of work to complete
|
||||||
|
— and its own blockers are not dragged in behind it."""
|
||||||
|
self.serve(listed=[payload(1, "Closed thing", state="closed"),
|
||||||
|
payload(2, "Open thing")],
|
||||||
|
extra=[payload(50, "Blocker of the closed one")],
|
||||||
|
deps={1: [50]})
|
||||||
|
self.run_pull("-q", "x", "--state", "all")
|
||||||
|
self.assertEqual(self.fake.paths("/dependencies"),
|
||||||
|
["%s/issues/2/dependencies" % BASE])
|
||||||
|
self.assertEqual(self.stored(), ["open-thing"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
`pull.py --limit N` bounds the WRITE, not the selection.
|
||||||
|
|
||||||
|
The bug this file exists to keep dead: the limit used to cut the list of
|
||||||
|
payloads before pull.py dropped the closed ones, so a milestone whose first
|
||||||
|
issues are closed spent the budget on issues that never reached disk —
|
||||||
|
`--limit 20` wrote twelve, and the docstring promised twenty.
|
||||||
|
|
||||||
|
What is asserted, in the order the fix has to hold it:
|
||||||
|
|
||||||
|
1. **The count is of files.** N issues under the filter that would be stored →
|
||||||
|
exactly N files, however many closed ones were enumerated on the way.
|
||||||
|
2. **Pagination serves the budget.** More pages are requested while the budget
|
||||||
|
is unfilled, and the page after the one that fills it is never requested.
|
||||||
|
3. **The scan is bounded.** A filter that matches almost only closed issues
|
||||||
|
stops after `_gitea.PAGE_SLACK` times the ideal page count, says so, and
|
||||||
|
returns short — it does not walk the tracker.
|
||||||
|
4. **`remote.py` is unchanged.** Its `--limit` still caps the listing, closed
|
||||||
|
issues included, because it writes nothing there is a limit for.
|
||||||
|
|
||||||
|
The transport is stubbed at `_gitea.api`, the way the other suites do it, and
|
||||||
|
the stub serves `page=` / `limit=` itself so the request pattern is a real
|
||||||
|
observation and not an assumption. No network, and no test writes to the
|
||||||
|
developer's store: each one builds its own in a `tempfile.TemporaryDirectory()`.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
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
|
||||||
|
import remote # noqa: E402
|
||||||
|
|
||||||
|
REPO = "claude-skills/tea"
|
||||||
|
BASE = "repos/%s" % REPO
|
||||||
|
|
||||||
|
BODY = """## Summary
|
||||||
|
Прозаическое описание задачи.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
skills/issue/references/format.md
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] что-нибудь работает
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def payload(number, state="open", title=None, comments=0):
|
||||||
|
return {"number": number, "title": title or "Issue number %d" % number,
|
||||||
|
"body": BODY, "state": state, "comments": comments,
|
||||||
|
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
||||||
|
"ref": "main", "updated_at": "2026-08-10T00:00:00Z",
|
||||||
|
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||||
|
"repository": {"full_name": REPO}}
|
||||||
|
|
||||||
|
|
||||||
|
def alternating(count, first="closed"):
|
||||||
|
"""`count` issues, every other one closed. The shape of the bug report:
|
||||||
|
closed issues sitting in front of the open ones, in page order."""
|
||||||
|
other = "open" if first == "closed" else "closed"
|
||||||
|
return [payload(n, first if n % 2 else other) for n in range(1, count + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTracker(object):
|
||||||
|
"""`tea api` answered from a list, with real pagination.
|
||||||
|
|
||||||
|
It slices on the `page=` and `limit=` it was given rather than ignoring
|
||||||
|
them, so "which pages were requested" is something the test can read off
|
||||||
|
`self.list_pages` instead of inferring."""
|
||||||
|
|
||||||
|
def __init__(self, payloads):
|
||||||
|
self.payloads = list(payloads)
|
||||||
|
self.list_pages = [] # (page, per_page), in request order
|
||||||
|
|
||||||
|
def api(self, login, endpoint, method="GET", payload=None,
|
||||||
|
payload_name=None, out_root=None, allow_fail=False):
|
||||||
|
path, _, qs = endpoint.partition("?")
|
||||||
|
q = urllib.parse.parse_qs(qs)
|
||||||
|
|
||||||
|
if path == "%s/issues" % BASE and method == "GET":
|
||||||
|
page, per = int(q["page"][0]), int(q["limit"][0])
|
||||||
|
self.list_pages.append((page, per))
|
||||||
|
return self.payloads[(page - 1) * per:(page - 1) * per + per]
|
||||||
|
|
||||||
|
if path.endswith("/comments"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if path.endswith("/dependencies"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if "/issues/" in path and method == "GET":
|
||||||
|
n = int(path.rsplit("/", 1)[1])
|
||||||
|
for p in self.payloads:
|
||||||
|
if p["number"] == n:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||||
|
|
||||||
|
|
||||||
|
class PullLimitTestCase(unittest.TestCase):
|
||||||
|
"""A temp store, a fake tracker, no git and no network."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory(prefix="tea-limit-")
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = os.path.join(self.tmp.name, "tmp", "issues")
|
||||||
|
os.makedirs(self.root)
|
||||||
|
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
|
||||||
|
# -- runners -----------------------------------------------------------
|
||||||
|
|
||||||
|
def serve(self, payloads):
|
||||||
|
self.fake = FakeTracker(payloads)
|
||||||
|
p = mock.patch.object(_gitea, "api", self.fake.api)
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
return self.fake
|
||||||
|
|
||||||
|
def run_pull(self, *argv):
|
||||||
|
return self._run(pull, "pull.py", argv)
|
||||||
|
|
||||||
|
def run_remote(self, *argv):
|
||||||
|
return self._run(remote, "remote.py", argv)
|
||||||
|
|
||||||
|
def _run(self, mod, name, argv):
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(out), \
|
||||||
|
contextlib.redirect_stderr(err):
|
||||||
|
mod.main()
|
||||||
|
return out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
# -- assertions --------------------------------------------------------
|
||||||
|
|
||||||
|
def stored(self):
|
||||||
|
return sorted(issue.all_ids(self.root))
|
||||||
|
|
||||||
|
def assertStoredCount(self, n, why=""):
|
||||||
|
got = self.stored()
|
||||||
|
self.assertEqual(len(got), n, "%d issue(s) in the store, wanted %d%s: %s"
|
||||||
|
% (len(got), n, why and " — " + why, got))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 1. the count is of files
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LimitCountsWritesTest(PullLimitTestCase):
|
||||||
|
|
||||||
|
def test_closed_issues_do_not_spend_the_budget(self):
|
||||||
|
"""The regression. Half the selection is closed and stands in front of
|
||||||
|
the open ones; the limit still buys ten files."""
|
||||||
|
self.serve(alternating(40))
|
||||||
|
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertStoredCount(10)
|
||||||
|
|
||||||
|
def test_only_open_issues_landed(self):
|
||||||
|
self.serve(alternating(40))
|
||||||
|
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
for id in self.stored():
|
||||||
|
self.assertEqual(issue.load(self.root, id).state, "open")
|
||||||
|
|
||||||
|
def test_the_dropped_ones_are_still_reported(self):
|
||||||
|
"""Enumerated-and-dropped is not silence: the closed ones seen on the
|
||||||
|
pages that were fetched are counted on stderr."""
|
||||||
|
self.serve(alternating(40))
|
||||||
|
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertIn("closed issue(s) enumerated, not stored", err)
|
||||||
|
|
||||||
|
def test_a_closed_issue_already_in_the_store_spends_it(self):
|
||||||
|
"""It is refreshed rather than dropped — that is a write, so it counts.
|
||||||
|
The limit is on what the store holds when the run ends, and this issue
|
||||||
|
is in it."""
|
||||||
|
kept = issue.Issue(id="already-here", title="Already here", body=BODY,
|
||||||
|
labels=["type/task"], origin="gitea",
|
||||||
|
extra={"gitea": gmap.remote_key(REPO, 1)})
|
||||||
|
issue.save(self.root, kept)
|
||||||
|
_gitea.save_map(self.root, {gmap.remote_key(REPO, 1): "already-here"})
|
||||||
|
|
||||||
|
self.serve(alternating(40)) # #1 is closed, and is on disk
|
||||||
|
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertStoredCount(10)
|
||||||
|
self.assertEqual(issue.load(self.root, "already-here").state, "closed",
|
||||||
|
"a stored issue must learn it was closed")
|
||||||
|
|
||||||
|
def test_state_closed_writes_closed_ones(self):
|
||||||
|
"""Nothing above may leak into the mode where closed IS the selection."""
|
||||||
|
self.serve([payload(n, "closed") for n in range(1, 21)])
|
||||||
|
self.run_pull("-q", "x", "--state", "closed", "--limit", "6")
|
||||||
|
self.assertStoredCount(6)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 2. pagination serves the budget
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PaginationFollowsTheBudgetTest(PullLimitTestCase):
|
||||||
|
|
||||||
|
def test_more_pages_are_fetched_until_the_budget_is_full(self):
|
||||||
|
"""One page of ten holds five open issues, so ten files cost two."""
|
||||||
|
self.serve(alternating(40))
|
||||||
|
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertStoredCount(10)
|
||||||
|
self.assertEqual([p for p, _ in self.fake.list_pages], [1, 2])
|
||||||
|
|
||||||
|
def test_the_page_after_the_last_needed_one_is_never_requested(self):
|
||||||
|
"""The budget fills inside page 2; page 3 exists and must not be asked
|
||||||
|
for. Bounding the write must not become fetching the whole repo."""
|
||||||
|
self.serve(alternating(200))
|
||||||
|
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertEqual(len(self.fake.list_pages), 2,
|
||||||
|
"extra pages requested: %r" % (self.fake.list_pages,))
|
||||||
|
|
||||||
|
def test_an_unfiltered_selection_still_costs_one_page(self):
|
||||||
|
"""Nothing is dropped, so nothing changes: the old arithmetic holds."""
|
||||||
|
self.serve([payload(n) for n in range(1, 60)])
|
||||||
|
self.run_pull("-q", "x", "--limit", "10")
|
||||||
|
self.assertStoredCount(10)
|
||||||
|
self.assertEqual(len(self.fake.list_pages), 1)
|
||||||
|
|
||||||
|
def test_running_out_of_pages_gives_a_short_answer(self):
|
||||||
|
"""Six issues, three of them open, `--limit 10`: three files, no crash,
|
||||||
|
and no page beyond the last."""
|
||||||
|
self.serve(alternating(6))
|
||||||
|
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertStoredCount(3)
|
||||||
|
self.assertEqual(len(self.fake.list_pages), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 3. the scan is bounded
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ScanIsBoundedTest(PullLimitTestCase):
|
||||||
|
|
||||||
|
def test_a_selection_of_only_closed_issues_stops_at_the_page_budget(self):
|
||||||
|
self.serve([payload(n, "closed") for n in range(1, 501)])
|
||||||
|
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertStoredCount(0)
|
||||||
|
self.assertEqual(len(self.fake.list_pages), _gitea.PAGE_SLACK,
|
||||||
|
"the scan walked past its budget: %r" % (self.fake.list_pages,))
|
||||||
|
self.assertIn("short of --limit", err)
|
||||||
|
|
||||||
|
def test_a_full_budget_does_not_warn(self):
|
||||||
|
"""The warning means "there may be more"; it must not fire on a run
|
||||||
|
that got everything it asked for."""
|
||||||
|
self.serve(alternating(40))
|
||||||
|
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertNotIn("short of --limit", err)
|
||||||
|
|
||||||
|
def test_a_selection_that_ran_out_does_not_warn(self):
|
||||||
|
"""Six issues in the repo and the server said so — that is an answer,
|
||||||
|
not a truncation."""
|
||||||
|
self.serve(alternating(6))
|
||||||
|
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertNotIn("short of --limit", err)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 4. remote.py is the deliberate exception
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RemoteListingIsUnchangedTest(PullLimitTestCase):
|
||||||
|
|
||||||
|
def test_the_listing_limit_still_counts_lines_not_writes(self):
|
||||||
|
"""remote.py writes nothing, so there is no write to bound: ten lines
|
||||||
|
out, closed ones among them, one request."""
|
||||||
|
self.serve(alternating(40))
|
||||||
|
out, _ = self.run_remote("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
numbered = [l for l in out.splitlines() if l.startswith("#")]
|
||||||
|
self.assertEqual(len(numbered), 10)
|
||||||
|
self.assertTrue(any("closed" in l for l in numbered),
|
||||||
|
"a listing that hides closed issues is not a listing")
|
||||||
|
self.assertEqual(len(self.fake.list_pages), 1)
|
||||||
|
|
||||||
|
def test_it_leaves_the_store_alone(self):
|
||||||
|
self.serve(alternating(40))
|
||||||
|
self.run_remote("-q", "x", "--state", "all", "--limit", "10")
|
||||||
|
self.assertStoredCount(0, "discovery wrote to the store")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the transport on its own
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ListIssuesKeepTest(PullLimitTestCase):
|
||||||
|
"""`_gitea.list_issues` without a caller in front of it — the counting rule
|
||||||
|
is the transport's, and it is testable without a store."""
|
||||||
|
|
||||||
|
def list(self, payloads, **kw):
|
||||||
|
self.serve(payloads)
|
||||||
|
return _gitea.list_issues("test-login", BASE, state="all", **kw)
|
||||||
|
|
||||||
|
def test_without_keep_the_limit_caps_the_selection(self):
|
||||||
|
got, _ = self.list(alternating(40), limit=10)
|
||||||
|
self.assertEqual(len(got), 10)
|
||||||
|
|
||||||
|
def test_with_keep_the_limit_caps_the_kept(self):
|
||||||
|
got, _ = self.list(alternating(40), limit=10,
|
||||||
|
keep=lambda p: p["state"] == "open")
|
||||||
|
self.assertEqual(len([p for p in got if p["state"] == "open"]), 10)
|
||||||
|
|
||||||
|
def test_the_rejected_ones_come_back_too(self):
|
||||||
|
"""They were enumerated. The caller reports them; the transport does
|
||||||
|
not get to throw away what it did not count."""
|
||||||
|
got, _ = self.list(alternating(40), limit=10,
|
||||||
|
keep=lambda p: p["state"] == "open")
|
||||||
|
self.assertTrue([p for p in got if p["state"] == "closed"])
|
||||||
|
|
||||||
|
def test_a_limit_below_one_is_refused(self):
|
||||||
|
"""The page arithmetic divides by the page size, and a limit of zero
|
||||||
|
used to make that a traceback. It is a usage error, so it reads like
|
||||||
|
one."""
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
self.list(alternating(4), limit=0)
|
||||||
|
|
||||||
|
def test_pull_requests_never_count(self):
|
||||||
|
"""`matches` drops them, so they cannot spend the budget either."""
|
||||||
|
mixed = []
|
||||||
|
for n in range(1, 41):
|
||||||
|
p = payload(n)
|
||||||
|
if n % 2:
|
||||||
|
p["pull_request"] = {"merged": False}
|
||||||
|
mixed.append(p)
|
||||||
|
got, _ = self.list(mixed, limit=10, keep=lambda p: True)
|
||||||
|
self.assertEqual(len(got), 10)
|
||||||
|
self.assertFalse([p for p in got if p.get("pull_request")])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -97,7 +97,7 @@ class FakeGitea(object):
|
|||||||
# -- the seam ----------------------------------------------------------
|
# -- the seam ----------------------------------------------------------
|
||||||
|
|
||||||
def api(self, login, endpoint, method="GET", payload=None,
|
def api(self, login, endpoint, method="GET", payload=None,
|
||||||
payload_name=None, out_root=None, allow_fail=False):
|
payload_name=None, allow_fail=False):
|
||||||
self.calls.append((method, endpoint, payload))
|
self.calls.append((method, endpoint, payload))
|
||||||
path = endpoint.split("?")[0]
|
path = endpoint.split("?")[0]
|
||||||
|
|
||||||
@@ -153,12 +153,27 @@ class PushTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
# -- fixtures ----------------------------------------------------------
|
# -- fixtures ----------------------------------------------------------
|
||||||
|
|
||||||
def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None):
|
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"],
|
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
||||||
depends=list(depends), extra=dict(extra or {}))
|
depends=list(depends), origin=origin,
|
||||||
|
extra=dict(extra or {}))
|
||||||
issue.save(self.root, iss)
|
issue.save(self.root, iss)
|
||||||
return 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):
|
def two_issues(self):
|
||||||
"""first-thing, and second-thing which depends on it."""
|
"""first-thing, and second-thing which depends on it."""
|
||||||
self.write_issue("first-thing", "First thing")
|
self.write_issue("first-thing", "First thing")
|
||||||
@@ -175,7 +190,14 @@ class PushTestCase(unittest.TestCase):
|
|||||||
return out.getvalue(), err.getvalue()
|
return out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
def number_of(self, id):
|
def number_of(self, id):
|
||||||
return gmap.number_of(issue.load(self.root, 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
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -196,7 +218,7 @@ class AddDependencyTest(unittest.TestCase):
|
|||||||
return {"number": 102}
|
return {"number": 102}
|
||||||
|
|
||||||
with mock.patch.object(_gitea, "api", fake_api):
|
with mock.patch.object(_gitea, "api", fake_api):
|
||||||
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101, out_root=None)
|
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101)
|
||||||
|
|
||||||
self.assertTrue(ok)
|
self.assertTrue(ok)
|
||||||
method, endpoint, payload = calls[0]
|
method, endpoint, payload = calls[0]
|
||||||
@@ -293,6 +315,8 @@ class IdempotenceTest(PushTestCase):
|
|||||||
self.run_push()
|
self.run_push()
|
||||||
self.assertEqual(len(self.fake.dep_posts()), 1)
|
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.run_push("--update")
|
||||||
self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed")
|
self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed")
|
||||||
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
||||||
@@ -318,10 +342,9 @@ class UpdateCarriesNewLinksTest(PushTestCase):
|
|||||||
self.run_push()
|
self.run_push()
|
||||||
self.assertEqual(self.fake.dep_posts(), [])
|
self.assertEqual(self.fake.dep_posts(), [])
|
||||||
|
|
||||||
iss = issue.load(self.root, "second-thing")
|
# The issue comes back from Gitea, and the dependency is added to the
|
||||||
iss.depends = ["first-thing"]
|
# copy that came back — there is no other copy to add it to.
|
||||||
iss.body = BODY
|
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
||||||
issue.save(self.root, iss)
|
|
||||||
|
|
||||||
self.run_push("--update", "second-thing")
|
self.run_push("--update", "second-thing")
|
||||||
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
||||||
@@ -356,10 +379,13 @@ class DryRunTest(PushTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class BodyIsVerbatimTest(PushTestCase):
|
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):
|
def test_depends_on_prose_is_not_rewritten_to_numbers(self):
|
||||||
"""map.py deliberately never edits the prose. Linking must not start."""
|
"""map.py deliberately never edits the prose. Linking must not start."""
|
||||||
self.two_issues()
|
self.two_issues()
|
||||||
|
before = issue.load(self.root, "second-thing").body
|
||||||
self.run_push()
|
self.run_push()
|
||||||
|
|
||||||
created = [c for c in self.fake.calls
|
created = [c for c in self.fake.calls
|
||||||
@@ -369,16 +395,20 @@ class BodyIsVerbatimTest(PushTestCase):
|
|||||||
|
|
||||||
self.assertIn("- first-thing — ставит фундамент", second_body)
|
self.assertIn("- first-thing — ставит фундамент", second_body)
|
||||||
self.assertNotIn("#101", second_body)
|
self.assertNotIn("#101", second_body)
|
||||||
self.assertEqual(second_body, issue.load(self.root, "second-thing").body)
|
self.assertEqual(gmap.strip_id_marker(second_body), before)
|
||||||
|
|
||||||
def test_body_survives_a_second_push_unchanged(self):
|
def test_body_survives_a_second_push_unchanged(self):
|
||||||
self.two_issues()
|
self.two_issues()
|
||||||
self.run_push()
|
|
||||||
before = issue.load(self.root, "second-thing").body
|
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")
|
self.run_push("--update")
|
||||||
patched = [c for c in self.fake.calls if c[0] == "PATCH"]
|
patched = [c for c in self.fake.calls if c[0] == "PATCH"]
|
||||||
self.assertIn(before, [c[2]["body"] for c in patched])
|
self.assertIn(before, [gmap.strip_id_marker(c[2]["body"]) for c in patched])
|
||||||
self.assertEqual(before, issue.load(self.root, "second-thing").body)
|
|
||||||
|
|
||||||
|
|
||||||
class DepStateTest(PushTestCase):
|
class DepStateTest(PushTestCase):
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import unittest
|
|||||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||||
|
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||||
|
|
||||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||||
import issue # noqa: E402
|
import issue # noqa: E402
|
||||||
@@ -110,6 +111,8 @@ class FakeRepo(object):
|
|||||||
skip = shutil.ignore_patterns("__pycache__")
|
skip = shutil.ignore_patterns("__pycache__")
|
||||||
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
||||||
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
|
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
|
||||||
|
# the transport resolves the login pin through skills/auth/scripts
|
||||||
|
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
|
||||||
os.makedirs(self.path("sub", "deeper"))
|
os.makedirs(self.path("sub", "deeper"))
|
||||||
|
|
||||||
if with_store:
|
if with_store:
|
||||||
@@ -397,9 +400,10 @@ class TestSyncLayerAgrees(unittest.TestCase):
|
|||||||
"""Both layers agree by construction, not by coincidence: no script
|
"""Both layers agree by construction, not by coincidence: no script
|
||||||
spells the default out for itself."""
|
spells the default out for itself."""
|
||||||
for layer, names in (("issue", ("issue_new.py", "issue_check.py",
|
for layer, names in (("issue", ("issue_new.py", "issue_check.py",
|
||||||
"issue_tree.py", "issue_index.py")),
|
"issue_tree.py", "issue_index.py",
|
||||||
|
"issue_evict.py")),
|
||||||
("sync", ("pull.py", "push.py", "remote.py",
|
("sync", ("pull.py", "push.py", "remote.py",
|
||||||
"comment.py"))):
|
"comment.py", "evict.py"))):
|
||||||
for name in names:
|
for name in names:
|
||||||
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
|
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
|
||||||
src = f.read()
|
src = f.read()
|
||||||
|
|||||||
Reference in New Issue
Block a user