Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a8bd1c299 | |||
| 81119a3bd9 | |||
| f97ac952f7 | |||
| 493a787940 | |||
| 7ab967bfaa | |||
| edb2f5a627 | |||
| 62027db76c | |||
| 9479babfe9 | |||
| e330a11e8f | |||
| bf0936526d | |||
| f7cffd7c48 | |||
| e6b4cf773c | |||
| 17567cd6a2 | |||
| 2f82b501bd |
@@ -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,18 +15,15 @@
|
||||
|
||||
## 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 tea api, pagination, filters, payloads
|
||||
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
|
||||
transport is _gitea.py — there is no second one
|
||||
│ imports
|
||||
▼
|
||||
skills/auth IDENTITY pin the login the whole tracker side runs under
|
||||
@@ -40,19 +37,16 @@ skills/use REFERENCE tea CLI docs for everything that is not an issue
|
||||
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
|
||||
|
||||
@@ -68,6 +62,8 @@ 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
|
||||
@@ -75,22 +71,10 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
||||
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
|
||||
@@ -121,7 +105,7 @@ 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`/`page.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.**
|
||||
`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
|
||||
@@ -145,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.
|
||||
|
||||
@@ -200,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
|
||||
|
||||
@@ -120,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:
|
||||
@@ -135,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
|
||||
@@ -145,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
|
||||
|
||||
+21
-20
@@ -30,11 +30,9 @@ to fill the gap yourself.
|
||||
Load the skill, do not remember the flags:
|
||||
|
||||
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `close.py`, `remote.py`,
|
||||
`labels.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
|
||||
@@ -45,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
|
||||
@@ -56,25 +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.
|
||||
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. **Deleting and
|
||||
retitling stay forbidden** on both sides — on the wiki that means no
|
||||
`--retitle`, since 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.
|
||||
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
|
||||
|
||||
+176
-28
@@ -13,7 +13,7 @@ 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. WHERE that file is looked for is not decided
|
||||
here: skills/auth/scripts/pin.py holds the search order, and the sync and wiki
|
||||
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
|
||||
@@ -27,6 +27,27 @@ 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.
|
||||
"""
|
||||
@@ -46,6 +67,131 @@ except Exception:
|
||||
|
||||
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")
|
||||
@@ -81,34 +227,37 @@ 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.')
|
||||
|
||||
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 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)
|
||||
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()
|
||||
|
||||
if pin is None:
|
||||
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
|
||||
@@ -123,8 +272,7 @@ def main():
|
||||
'.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(login) + cmd[m.end(3):]
|
||||
rewrite(tool_input, new_cmd,
|
||||
rewrite(tool_input, substitute(cmd, login),
|
||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ So:
|
||||
## 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/wiki transport — they cannot disagree about a
|
||||
`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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 and wiki script runs on. Two
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -207,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())
|
||||
+57
-2
@@ -45,6 +45,7 @@ there is nothing to pin a second time. No pin anywhere → exit with a pointer t
|
||||
| `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` |
|
||||
@@ -354,8 +355,11 @@ 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.
|
||||
@@ -414,6 +418,57 @@ 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 |
|
||||
|
||||
@@ -48,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.
|
||||
|
||||
|
||||
@@ -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,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()
|
||||
@@ -1,522 +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"))
|
||||
# auth is in the list because the transport resolves the login pin
|
||||
# through skills/auth/scripts/pin.py — one search order, one module.
|
||||
for layer in ("page", "wiki", "sync", "auth"):
|
||||
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
||||
os.path.join(self.root, "skills", layer, "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,6 @@ 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)
|
||||
@@ -44,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 "[]")
|
||||
'''
|
||||
|
||||
@@ -223,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"):
|
||||
@@ -236,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")
|
||||
|
||||
|
||||
@@ -400,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