Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a8bd1c299 | |||
| 81119a3bd9 | |||
| f97ac952f7 | |||
| 493a787940 | |||
| 7ab967bfaa | |||
| edb2f5a627 | |||
| 62027db76c | |||
| 9479babfe9 | |||
| e330a11e8f | |||
| bf0936526d | |||
| f7cffd7c48 | |||
| e6b4cf773c | |||
| 17567cd6a2 | |||
| f5977fa4fc | |||
| 74a0e3b173 | |||
| bb964d5a55 | |||
| 1d7abc11ae | |||
| 2f82b501bd | |||
| 9679e2c000 |
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"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",
|
||||
"author": {
|
||||
"name": "naudachu"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": ["gitea", "cli", "git", "issues", "wiki", "login-guard"]
|
||||
"keywords": ["gitea", "cli", "git", "issues", "login-guard"]
|
||||
}
|
||||
|
||||
@@ -15,42 +15,44 @@
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
skills/page DOMAIN what a page tree is: title <-> path, order, the index
|
||||
▲ offline — no tracker, no network, stdlib imports only
|
||||
│ imports
|
||||
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
||||
_gitea.py login pin, tea api, pagination, filters
|
||||
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
|
||||
transport is _gitea.py — there is no second one
|
||||
skills/use REFERENCE tea CLI docs for everything that is not an issue
|
||||
_gitea.py tea api, pagination, filters, payloads
|
||||
│ imports
|
||||
▼
|
||||
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
|
||||
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
||||
```
|
||||
|
||||
A domain never imports its bridge, and the two domains do not import each
|
||||
other: delete `skills/sync` and issues still work, delete `skills/wiki` and page
|
||||
trees still work, delete either domain and the other is untouched. The check is
|
||||
mechanical — every import under a domain's `scripts/` is stdlib, and
|
||||
`subprocess` is not among them:
|
||||
The domain never imports its bridge: delete `skills/sync` and issues still
|
||||
work. The check is mechanical — every import under the domain's `scripts/` is
|
||||
stdlib, and `subprocess` is not among them:
|
||||
|
||||
```bash
|
||||
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
||||
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
|
||||
```
|
||||
|
||||
If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
||||
`content_base64`) shows up in a domain layer, it is in the wrong place.
|
||||
If a tracker concept (issue number, login, HTTP call, label color) shows up in
|
||||
the domain layer, it is in the wrong place.
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `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
|
||||
- `references/format.md` — canonical issue format; single source of truth
|
||||
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
||||
@@ -60,28 +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
|
||||
substring, changing exactly one character of the file
|
||||
- `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`
|
||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
||||
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
||||
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
|
||||
label ids, the remote-id map, `tmp/payload/`
|
||||
- `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids,
|
||||
the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py`
|
||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
||||
- `scripts/close.py` — the state field, both ways; explicit ids only
|
||||
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
|
||||
the domain's `issue_evict.run`
|
||||
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
|
||||
repository; reads the domain taxonomy, never the store
|
||||
- `skills/page` — a discussion's artifacts as a page tree (`/tea:page`),
|
||||
entirely offline
|
||||
- `references/pages.md` — canonical page-tree format; single source of truth
|
||||
- `scripts/page.py` — domain module: title ↔ path, ordering, the manifest,
|
||||
importing a directory of markdown, the index
|
||||
- `scripts/page_import.py` — copy a directory of markdown into a space,
|
||||
titling every file
|
||||
- `scripts/page_index.py` — write the table-of-contents page
|
||||
- `scripts/page_ls.py` — the tree, the titles, one sync-state tag per page
|
||||
- `skills/wiki` — move page trees between a local space and a Gitea wiki
|
||||
(`/tea:wiki`)
|
||||
- `scripts/wikimap.py` — md ↔ Gitea wiki JSON, pure, no I/O
|
||||
- `scripts/wiki_ls.py`, `wiki_pull.py`, `wiki_push.py` — transport is
|
||||
`skills/sync/scripts/_gitea.py`
|
||||
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
||||
(`/tea:use`); `references/tea/` holds the command docs
|
||||
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
||||
@@ -91,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
|
||||
loop, the retry, and the error triage.
|
||||
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
||||
that don't use the pinned login; `agents-sync` keeps every directory canonical
|
||||
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
||||
that don't use the pinned login (resolving it through `auth/pin.py`);
|
||||
`agents-sync` keeps every directory canonical (`AGENTS.md` real file,
|
||||
`CLAUDE.md` symlink to it)
|
||||
- `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
|
||||
|
||||
```bash
|
||||
@@ -106,11 +129,10 @@ stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
|
||||
packages, so a test that needs the domain module imports it with
|
||||
`sys.path.insert`.
|
||||
|
||||
**A test never touches `tmp/issues/`, `tmp/wiki/` or `tmp/payload/`.** Anything
|
||||
that needs a store builds a throwaway repository in a
|
||||
`tempfile.TemporaryDirectory()` — a `.git` marker, a copy of the script layers,
|
||||
fixture issues or artifacts — and runs the real scripts inside it as
|
||||
subprocesses. That is the only way to test behavior that depends on where a
|
||||
**A test never touches `tmp/issues/` or `tmp/payload/`.** Anything that needs a
|
||||
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
||||
`.git` marker, a copy of the script layers, fixture issues — and runs the real
|
||||
scripts inside it as subprocesses. That is the only way to test behavior that depends on where a
|
||||
script is run from, and it keeps the developer's own store out of the blast
|
||||
radius.
|
||||
|
||||
@@ -161,48 +183,42 @@ line so plain grep works without a parser.
|
||||
- `.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.
|
||||
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
|
||||
|
||||
`tmp/wiki/<space>/` (gitignored) holds page trees — a discussion's artifacts,
|
||||
organized. Same stance as the issue store, resolved the same way from
|
||||
`page.py`'s own location, with the same `--out` rule.
|
||||
|
||||
- Identity is the **title**, and `/` inside it is the only hierarchy there is.
|
||||
The Gitea wiki is flat: it escapes a title into one filename by rules of its
|
||||
own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`).
|
||||
- **`sub_url` is Gitea's address for a page and is never constructed.** It is
|
||||
read back from the API and stored in `.pages.json`. One built by hand that is
|
||||
almost right creates a second page instead of editing the first.
|
||||
- **Never commit a subdirectory into a wiki's git repository.** Gitea does not
|
||||
see it — the page exists on disk and nowhere in the API or the UI. Do not
|
||||
clone the wiki repo to work in; use the scripts.
|
||||
- A title is a decision, not a derivation. A re-import replaces bodies and
|
||||
keeps titles, so editing a heading cannot silently rename a published page.
|
||||
`--retitle` opts in, and the rename reaches the wiki on the next push.
|
||||
- A page with no `sub_url` has never been published — a complete state, the way
|
||||
`origin: local` is for an issue. **The parallel stops at the push**: a pushed
|
||||
page stays on disk, a pushed issue does not.
|
||||
- Change detection is one hash (`pushed`). Pulling overwrites; pushing is
|
||||
additive and never deletes — the one place the two domains deliberately
|
||||
disagree, because a page tree is worked on locally and an issue is not.
|
||||
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
|
||||
`_gitea.py`.
|
||||
|
||||
## Request payloads
|
||||
|
||||
`tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
|
||||
one file per named request, kept after the call for a retry or a post-mortem.
|
||||
It is **not a store and holds nobody's only copy** — deleting it costs nothing.
|
||||
|
||||
- One directory for every caller — sync and wiki both — resolved from
|
||||
`_gitea.py`'s own location, so which command wrote a body does not change
|
||||
where it landed. `_gitea.api` takes no directory argument; that it once did
|
||||
- One directory for every caller, resolved from `_gitea.py`'s own location, so
|
||||
which command wrote a body does not change where it landed. `_gitea.api` takes no directory argument; that it once did
|
||||
is exactly how a label bootstrap came to create `tmp/issues/`.
|
||||
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
|
||||
or a run with nothing to send leaves no directory behind.
|
||||
|
||||
@@ -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: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-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 |
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
▲ offline — no tracker, no network, stdlib only
|
||||
│ imports
|
||||
skills/sync BRIDGE md <-> Gitea JSON, then over the wire
|
||||
skills/sync BRIDGE md <-> Gitea issue JSON, then over the wire
|
||||
▲
|
||||
│ calls
|
||||
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
||||
```
|
||||
|
||||
Delete `skills/sync` and the domain layer keeps working — issues that live only
|
||||
on your machine are first-class, not drafts waiting to be uploaded. That is the
|
||||
point of the split: you can plan, write, validate, and track work without a
|
||||
tracker, and publish only what you choose to.
|
||||
Delete `skills/sync` and the issue domain keeps working. Work that lives only
|
||||
on your machine is first-class, not a draft waiting to be uploaded. That is the
|
||||
point of the split: you can plan, write, and validate without a tracker, and
|
||||
publish only what you choose to.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -68,7 +68,9 @@ The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-gua
|
||||
|
||||
## 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
|
||||
@@ -80,7 +82,7 @@ right skill automatically. `/tea:auth` is only needed for the tracker side;
|
||||
|
||||
## 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:
|
||||
- running `tea` without `--login` at all
|
||||
@@ -118,11 +120,15 @@ session — the pinned login is enforced on every call it makes.
|
||||
agents/
|
||||
tea-runner.md subagent (Haiku) that executes the scripts
|
||||
hooks/
|
||||
hooks.json registers the PreToolUse hook
|
||||
hooks.json registers the PreToolUse hooks
|
||||
tea-guard.sh the guard (Python 3, no deps)
|
||||
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
|
||||
skills/
|
||||
auth/SKILL.md /tea:auth skill
|
||||
issue/ /tea:issue — the domain layer, offline
|
||||
auth/ /tea:auth — the identity layer
|
||||
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
|
||||
references/format.md canonical issue format (identity, types, templates)
|
||||
scripts/ Python 3, stdlib only, no network:
|
||||
@@ -133,6 +139,7 @@ skills/
|
||||
issue_check.py validate against the format
|
||||
issue_ac.py list the body's checkboxes; tick one
|
||||
issue_tree.py draw the dependency graph
|
||||
issue_evict.py drop closed issues the tracker also has
|
||||
issue_index.py rebuild tmp/issues/INDEX.md
|
||||
sync/ /tea:sync — the bridge to Gitea
|
||||
SKILL.md
|
||||
@@ -143,11 +150,17 @@ skills/
|
||||
push.py tmp/issues/ -> Gitea, then drops the local file
|
||||
remote.py discovery listing to stdout
|
||||
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)
|
||||
SKILL.md
|
||||
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
|
||||
|
||||
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
|
||||
|
||||
+28
-20
@@ -29,11 +29,10 @@ to fill the gap yourself.
|
||||
|
||||
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`,
|
||||
`issue_new.py`, `issue_ac.py`
|
||||
- `/tea:wiki` — `wiki_ls.py`, `wiki_pull.py`, `wiki_push.py`
|
||||
- `/tea:page` — `page_import.py`, `page_index.py`, `page_ls.py`
|
||||
`issue_new.py`, `issue_ac.py`, `issue_evict.py`
|
||||
|
||||
Invoke `Skill` with the one that owns the task at the start, and use the command
|
||||
table it gives you verbatim. The skill is the single source of
|
||||
@@ -44,8 +43,7 @@ instead of trying it.
|
||||
## Hard rules
|
||||
|
||||
1. **No raw `tea`.** Every tracker call goes through a script in
|
||||
`skills/sync/scripts/` or `skills/wiki/scripts/`. The one exception is a
|
||||
diagnostic the skill itself
|
||||
`skills/sync/scripts/`. The one exception is a diagnostic the skill itself
|
||||
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
|
||||
the `tea-guard` hook substitutes the pinned login. Never name a login.
|
||||
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
|
||||
@@ -55,20 +53,29 @@ instead of trying it.
|
||||
only the items the caller named, by the number or the substring the caller
|
||||
gave. Whether a criterion is actually met is a judgement about content, and
|
||||
content is never yours.
|
||||
3. **Push only what you were told to push.** `push.py` and `wiki_push.py`
|
||||
publish to a tracker other people read, **and `push.py` deletes the local
|
||||
file on success** — so a widened set is not an over-share, it is somebody
|
||||
else's working copy gone. Run them with the ids, titles, or filter the
|
||||
caller named. Never widen the set, never run a bare `push.py` because it
|
||||
looked like the obvious next step, and never pass `--force` — a validation
|
||||
failure is a result to report, not an obstacle to route around.
|
||||
`wiki_push.py` needs `-m`; use the caller's words, never your own summary.
|
||||
Report the number and URL `push.py` printed; that is now the only address
|
||||
the issue has.
|
||||
4. **Do not close, delete, or retitle anything** on either side. On the wiki
|
||||
that means no `--retitle`: renaming a published page abandons the old one.
|
||||
The one deletion you may cause is push's own, on the issue you were told to
|
||||
push.
|
||||
3. **Push only what you were told to push.** `push.py` publishes to a tracker
|
||||
other people read, **and it deletes the local file on success** — so a
|
||||
widened set is not an over-share, it is somebody else's working copy gone.
|
||||
Run it with the ids, titles, or filter the caller named. Never widen the
|
||||
set, never run a bare `push.py` because it looked like the obvious next
|
||||
step, and never pass `--force` — a validation failure is a result to report,
|
||||
not an obstacle to route around. Report the number and URL `push.py`
|
||||
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
|
||||
permute flags looking for one that works.
|
||||
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
|
||||
@@ -122,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 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 |
|
||||
| `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 |
|
||||
|
||||
+202
-55
@@ -12,7 +12,12 @@ checking it:
|
||||
|
||||
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
|
||||
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:
|
||||
- not a `tea` command ............................. allow (passthrough)
|
||||
@@ -22,13 +27,171 @@ Rules:
|
||||
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
|
||||
- --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
|
||||
rewrite; exit 2 + stderr to block.
|
||||
"""
|
||||
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}"}
|
||||
|
||||
# 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):
|
||||
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
|
||||
@@ -53,30 +216,6 @@ def rewrite(tool_input, new_cmd, note):
|
||||
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():
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
@@ -88,45 +227,53 @@ def main():
|
||||
tool_input = payload.get("tool_input") or {}
|
||||
cmd = tool_input.get("command") or ""
|
||||
|
||||
# Not a `tea` invocation → not our concern.
|
||||
if not re.search(r'(^|[;&|(]|\s)tea(\s|$)', cmd):
|
||||
allow_passthrough()
|
||||
try:
|
||||
runs = tea_invocations(shell_words(cmd))
|
||||
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
|
||||
# needs `tea logins list` while no pin exists yet.
|
||||
if re.search(r'tea\s+(logins\s+(list|ls)|--version|-v|--help|help)(\s|$)', cmd):
|
||||
if runs is None:
|
||||
if not LOOKS_LIKE_TEA.search(cmd):
|
||||
allow_passthrough()
|
||||
|
||||
# Locate --login / -l and its value (logins never contain spaces).
|
||||
m = re.search(r'(--login|(?<![\w-])-l)(\s+|=)(\S+)', cmd)
|
||||
m = LOGIN_FLAG.search(cmd)
|
||||
if not m:
|
||||
block('every `tea` command must include --login "$GITEA_LOGIN" '
|
||||
'(the guard substitutes the operator-pinned login). '
|
||||
'Run /tea:auth if no login is pinned.')
|
||||
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()
|
||||
|
||||
raw_val = m.group(3)
|
||||
inner = raw_val
|
||||
for q in ('"', "'"):
|
||||
if len(inner) >= 2 and inner[0] == q and inner[-1] == q:
|
||||
inner = inner[1:-1]
|
||||
break
|
||||
if pin is None:
|
||||
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
|
||||
'cannot be resolved. The plugin tree is incomplete; reinstall it.')
|
||||
|
||||
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:
|
||||
# The hint is the directory the Bash command will run in; the rest of the
|
||||
# order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the
|
||||
# search) is pin.py's, and is the same order the scripts get.
|
||||
login, src = pin.find_pin(payload.get("cwd"))
|
||||
if not login:
|
||||
block('no login is pinned. Run /tea:auth to choose one (writes '
|
||||
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
||||
'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, new_cmd,
|
||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (pin, src))
|
||||
rewrite(tool_input, substitute(cmd, login),
|
||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+27
-3
@@ -32,13 +32,37 @@ So:
|
||||
3. **One login:** propose it; confirm before writing.
|
||||
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
|
||||
`url` so the operator's choice is unambiguous. Never decide for them.
|
||||
5. Merge the chosen name into `.claude/settings.local.json` under `env`
|
||||
(do not clobber other keys):
|
||||
5. Merge the chosen name into the **project root's**
|
||||
`.claude/settings.local.json` under `env` (do not clobber other keys):
|
||||
```json
|
||||
{ "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
|
||||
`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
|
||||
|
||||
|
||||
@@ -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
|
||||
+48
-1
@@ -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_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_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.py` | the domain module the others import — not a command |
|
||||
|
||||
@@ -117,7 +118,9 @@ the table. Checkboxes are the exception — use `issue_ac.py`, below.
|
||||
|
||||
If the issue is synced (`origin: gitea`), the file is a working copy: your edit
|
||||
is local until you run `push.py --update` from `/tea:sync`, and that push
|
||||
**deletes the file** once Gitea has it. Nothing tracks drift, and with one copy
|
||||
**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.
|
||||
|
||||
@@ -205,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`
|
||||
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
|
||||
|
||||
`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
|
||||
tracker or not. Designed to be unambiguous for both humans and LLMs: fixed
|
||||
English section headers in a fixed order, verifiable acceptance criteria, one
|
||||
issue = one deliverable. Source spec: the project wiki
|
||||
([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
|
||||
issue = one deliverable.
|
||||
|
||||
Nothing here depends on Gitea. How these files are mapped onto a tracker is the
|
||||
sync layer's business — see `/tea:sync`.
|
||||
@@ -42,7 +41,6 @@ labels: [type/task, tech/sql]
|
||||
assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
wiki: [Simple Chains/Ideas/Chain core]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
@@ -64,7 +62,6 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
| `assignees` | domain | logins; may be empty |
|
||||
| `milestone` | domain | title, or `none` |
|
||||
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
||||
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them |
|
||||
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
||||
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
|
||||
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
|
||||
@@ -82,19 +79,24 @@ represent a local issue and a synced one without a second format.
|
||||
leaves this machine is valid and finished work; pushing it is optional and
|
||||
nothing here treats it as a draft.
|
||||
|
||||
It is not a *permanent* state, and this is the one place where the file's fate
|
||||
depends on it:
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file |
|
||||
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -113,7 +113,8 @@ ISSUE_ROOT = store_root()
|
||||
|
||||
# Domain-owned metadata, in render order. Foreign keys render after these,
|
||||
# sorted, so the sync layer can add fields without touching this list.
|
||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"]
|
||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
|
||||
"origin"]
|
||||
LIST_KEYS = {"labels", "assignees", "depends"}
|
||||
STATES = ("open", "closed")
|
||||
|
||||
@@ -247,8 +248,8 @@ class Issue(object):
|
||||
"""One unit of work. `extra` holds metadata this layer does not own."""
|
||||
|
||||
def __init__(self, id="", title="", body="", state="open", labels=None,
|
||||
assignees=None, milestone="", depends=None, origin=LOCAL,
|
||||
extra=None):
|
||||
assignees=None, milestone="", depends=None,
|
||||
origin=LOCAL, extra=None):
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.body = body
|
||||
@@ -635,6 +636,35 @@ def all_ids(root):
|
||||
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):
|
||||
with open(path_of(root, id)) as f:
|
||||
return Issue.from_text(f.read(), id=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())
|
||||
@@ -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())
|
||||
+120
-5
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
@@ -32,15 +32,22 @@ index.
|
||||
## Scripts
|
||||
|
||||
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
|
||||
operator's pin from `.claude/settings.local.json` themselves, the same source
|
||||
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
|
||||
operator's pin from `.claude/settings.local.json` through
|
||||
`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 |
|
||||
|---|---|
|
||||
| `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 [--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, **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 |
|
||||
| `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` |
|
||||
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
|
||||
|
||||
@@ -348,12 +355,120 @@ A milestone must already exist in the repo — push attaches, it does not create
|
||||
|
||||
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
|
||||
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
||||
and writes it back into the issue file; a value already there is never
|
||||
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
|
||||
and sends it up as `ref`; a value already there is never
|
||||
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
|
||||
one. Reading the branch is the only thing these scripts ask git for — they
|
||||
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
|
||||
|
||||
| domain | Gitea | note |
|
||||
|
||||
@@ -7,8 +7,10 @@ query quirks. It does NOT know what an issue is: no sections, no acceptance
|
||||
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
|
||||
lives one layer further out in skills/issue/scripts/issue.py.
|
||||
|
||||
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up
|
||||
from CWD — the same file /tea:auth writes and the tea-guard hook reads. No
|
||||
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
|
||||
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
|
||||
they will use. No pin -> exit with a pointer to /tea:auth.
|
||||
|
||||
@@ -46,9 +48,8 @@ PAGE_SLACK = 4
|
||||
# --------------------------------------------------------------------------
|
||||
# where request bodies land
|
||||
# --------------------------------------------------------------------------
|
||||
# Anchored on THIS FILE, like issue.store_root and page.store_root, so every
|
||||
# caller — sync, wiki, whatever comes next — writes to one directory whatever
|
||||
# it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
|
||||
# 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.
|
||||
|
||||
@@ -107,29 +108,32 @@ 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.
|
||||
|
||||
def find_pin(start_dir=None):
|
||||
"""Walk up from start_dir; return the login from the first
|
||||
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
|
||||
d = os.path.abspath(start_dir or ".")
|
||||
while True:
|
||||
p = os.path.join(d, ".claude", "settings.local.json")
|
||||
if os.path.isfile(p):
|
||||
try:
|
||||
with open(p) as f:
|
||||
v = (json.load(f).get("env") or {}).get("GITEA_LOGIN")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
except Exception:
|
||||
pass
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
_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():
|
||||
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:
|
||||
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
||||
return login
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
+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.
|
||||
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
|
||||
|
||||
- [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
|
||||
|
||||
- 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.
|
||||
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
||||
- 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:
|
||||
- `list, ls` (`--state`)
|
||||
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing.
|
||||
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash.
|
||||
- `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. 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`).
|
||||
**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>...`
|
||||
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
|
||||
- `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`.
|
||||
- `--header/-H key:value` (repeatable)
|
||||
- `--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.
|
||||
|
||||
@@ -14,9 +14,9 @@ Version: `tea 0.14.1` (go-sdk v0.25.1). Source: recursive `--help` traversal. Up
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--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 |
|
||||
| `--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) |
|
||||
| `--fields, -f <list>` | which columns to print |
|
||||
|
||||
|
||||
@@ -1,120 +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 in the same `tmp/payload/`.
|
||||
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,151 +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")
|
||||
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")
|
||||
|
||||
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 + "/")
|
||||
@@ -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,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()
|
||||
@@ -27,7 +27,7 @@ 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")
|
||||
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||
|
||||
sys.path.insert(0, SYNC_SCRIPTS)
|
||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||
@@ -43,7 +43,7 @@ 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", "sub_url": "Page"})
|
||||
sys.stdout.write(json.dumps({"id": 1, "name": "created"})
|
||||
if "-X" in sys.argv else "[]")
|
||||
'''
|
||||
|
||||
@@ -61,6 +61,8 @@ class FakeRepo(object):
|
||||
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
|
||||
@@ -220,7 +222,7 @@ class TestOnePlaceForEveryCaller(unittest.TestCase):
|
||||
def hits(self, needle, skip_transport=False):
|
||||
"""Every `layer/script.py:line` mentioning `needle`."""
|
||||
out = []
|
||||
for d in (SYNC_SCRIPTS, WIKI_SCRIPTS):
|
||||
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"):
|
||||
@@ -233,8 +235,8 @@ class TestOnePlaceForEveryCaller(unittest.TestCase):
|
||||
|
||||
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 two stores and a wiki space is the
|
||||
state this replaced."""
|
||||
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")
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ 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, ISSUE_SCRIPTS)
|
||||
import issue # noqa: E402
|
||||
@@ -110,6 +111,8 @@ class FakeRepo(object):
|
||||
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"))
|
||||
|
||||
if with_store:
|
||||
@@ -397,9 +400,10 @@ class TestSyncLayerAgrees(unittest.TestCase):
|
||||
"""Both layers agree by construction, not by coincidence: no script
|
||||
spells the default out for itself."""
|
||||
for layer, names in (("issue", ("issue_new.py", "issue_check.py",
|
||||
"issue_tree.py", "issue_index.py")),
|
||||
"issue_tree.py", "issue_index.py",
|
||||
"issue_evict.py")),
|
||||
("sync", ("pull.py", "push.py", "remote.py",
|
||||
"comment.py"))):
|
||||
"comment.py", "evict.py"))):
|
||||
for name in names:
|
||||
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
|
||||
src = f.read()
|
||||
|
||||
Reference in New Issue
Block a user