30 Commits

Author SHA1 Message Date
naudachu 27e4b6b1da merge: unify tdl and tea into one marketplace 2026-08-11 00:52:45 +05:00
naudachu 83f73c5cea refactor: turn the repo into a two-plugin marketplace
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.

The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.

tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.

test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:25:28 +05:00
naudachu 23f78beafb merge: drop the wiki and page layers 2026-08-10 22:31:09 +05:00
naudachu 5a8bd1c299 refactor!: drop the wiki and page layers
The plugin is issues and nothing else now. `skills/page` (the page-tree
domain) and `skills/wiki` (its bridge to a Gitea wiki) are gone, and
with them the issue domain's `wiki:` field — page titles were the only
thing that tied the two domains together, and a field the tracker has
no column for never came back from a pull anyway.

What is left is the shape AGENTS.md already claimed for the rest of the
repo: one domain, one bridge, one transport. The docs, the plugin
manifest, and tea-runner's skill table now say so too, and
test_payload_root walks the one script directory that remains.

Also removes openspec/config.yaml; nothing in the repo referenced it.

378 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:29:55 +05:00
naudachu 81119a3bd9 merge: bring the README layout and the branch claim back to the code 2026-08-10 20:07:23 +05:00
naudachu f97ac952f7 merge: separate the API-only flags from the ones needing a checkout 2026-08-10 20:07:23 +05:00
naudachu 493a787940 merge: implement the wiki field in the issue domain 2026-08-10 20:07:08 +05:00
naudachu 7ab967bfaa merge: guard the CLI command, not the word 2026-08-10 20:06:52 +05:00
naudachu edb2f5a627 docs: bring the README layout and the branch claim back to the code
README described a three-skill plugin that ships six. skills/page and
skills/wiki were absent from What it ships and from the project tree, so
/tea:page and /tea:wiki could not be discovered from the front page at
all; labels.py, close.py, evict.py, issue_evict.py, pin.py and the
agents-sync hook were missing from the tree too. Layout now matches
AGENTS.md, and says which of the two is authoritative.

The sync skill promised that push writes the computed branch back into
the issue file. It cannot: a successful push deletes the file, which
push.py:260-271 and its docstring already said. The paragraph now says
what happens instead — the ref goes up, and the branch comes back on the
next pull, from the tracker. The three claims around it were correct and
are kept.

Closes #27
Closes #31

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:03:27 +05:00
naudachu 62027db76c docs: separate the API-only flags from the ones needing a checkout
Two flags in the reference said one thing and did another.

`-o` was listed as a global output-format flag and the Tips section
recommended `-o json` without qualification. On `tea api` it is a file
name: `-o json` writes the body to a file called json and leaves stdout
empty, so the next parse fails with a JSONDecodeError that looks like a
server refusal. Scoped to entity commands, and api's meaning named in
all three places that mention it.

`--repo owner/repo` was documented as a general substitute for standing
in a checkout. For `pulls create`, `pulls checkout` and `pulls clean` it
is not: the slug is rejected with 'local repository required', advice
that reads like the flag was missing. Verified from outside any repo —
those three refuse a slug and accept a path, while pulls list,
milestones, releases and times take the slug from anywhere. All three
working forms are written down, including the git-worktree one (point
--repo at the main checkout) and the api fallback.

Closes #35
Closes #28

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:00:13 +05:00
naudachu 9479babfe9 feat: implement the wiki field in the issue domain
format.md listed `wiki:` among the domain fields, between depends and
origin, and issue.py had never heard of it. The field fell into extra and
rendered with the foreign keys — sorted in after the sync fields, which
the same document forbids one line below the table. Written without
brackets it parsed as a single string, and nothing but a text editor
could set it.

Implemented rather than de-documented: page_ls.py --titles already
prints these titles, so the field was designed and only unwired.
DOMAIN_KEYS and LIST_KEYS learn it, Issue carries it, and issue_new.py
gets a repeatable --wiki flag.

Titles only, as the format says: no path, no sub_url, no lookup. The
tracker has no field for it, so it is never sent and a pull does not
bring it back — format.md now says so.

Closes #32

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:55:10 +05:00
naudachu e330a11e8f fix: guard the tea command, not the word tea
The guard tested whether the command string contained `tea` between
whitespace. In a repository whose subject is the CLI, that blocked prose:
an issue title, a commit message quoting a raw call, `grep -rn " tea "`
and `echo tea`. The block message told the operator to add --login to
git commit, which cannot be done — the only way past was to reword the
sentence.

The command is now 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 after a shell
operator, past VAR=value assignments and prefix words. Quoting is what
saves the prose — a title is one token, and a token is never a command.

Every invocation in the line is checked and rewritten, not just the
first: a half-rewritten line left the second call with an unset variable
and no login. The whitelist is now per-invocation too, so quoting
"tea logins list" beside a real call no longer launders it.

An untokenizable line (unbalanced quotes) falls back to the old
substring test, which over-matches and therefore blocks.

Closes #29

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:49:38 +05:00
naudachu bf0936526d init openspec 2026-08-10 19:37:08 +05:00
claude f7cffd7c48 merge: evict closed issues from the local store 2026-08-10 13:32:50 +00:00
naudachu e6b4cf773c Merge origin/main into feat/evict-closed-issues
Three doc conflicts, all unions: the script lists in AGENTS.md and the runner
gain both close.py and evict.py, and the sync skill keeps both the closing and
the evicting sections. Rule 4 of the runner is rewritten once to carry both
halves — closing is now a script it may run on named ids, retitling and remote
deletion stay forbidden, and the two allowed local deletions (push's own, and
eviction) are listed together.
2026-08-10 18:32:39 +05:00
claude 17567cd6a2 merge: close issues through a script 2026-08-10 13:29:36 +00:00
naudachu f5977fa4fc Merge origin/main into feat/close-script
Two conflicts git could see (AGENTS.md, skills/sync/SKILL.md) and one it could
not: the payload-root change removed api()'s out_root parameter, so close.py
stops passing it, and its payload test now asserts PAYLOAD_ROOT instead of the
deleted PAYLOAD_DIR.
2026-08-10 18:29:25 +05:00
claude 74a0e3b173 merge: resolve the login pin from a git worktree 2026-08-10 13:25:14 +00:00
naudachu bb964d5a55 Merge remote-tracking branch 'origin/main' into fix/worktree-login-pin 2026-08-10 18:24:45 +05:00
claude 40016e06f2 merge: keep request payloads out of the issue store 2026-08-10 13:24:35 +00:00
naudachu 2a8da81359 Merge remote-tracking branch 'origin/main' into fix/no-store-for-label-payloads
# Conflicts:
#	skills/sync/scripts/_gitea.py
2026-08-10 18:23:39 +05:00
claude c18b16a14b merge: follow dependencies on every pull by default 2026-08-10 13:22:45 +00:00
claude cfbd6e5ddb merge: apply --limit to the write, not to the selection 2026-08-10 13:21:55 +00:00
naudachu 1d7abc11ae fix: resolve the login pin from a git worktree
`_gitea.require_login` walked up from CWD and nowhere else. A worktree is a
sibling of the main checkout, not a descendant, and `settings.local.json` is
untracked — so the pin lives in the main checkout only, is not on the
worktree's parent chain, and the whole tracker half of the plugin died there
with "no login pinned". In the same directory the guard resolved it fine,
because it had a search of its own: one order, written twice, disagreeing.

It is written once now, in skills/auth/scripts/pin.py, and both callers import
it — the transport and hooks/tea-guard.sh. $CLAUDE_PROJECT_DIR, then a hint the
caller supplies (the hook passes its payload's cwd), then the current
directory; each searched up its parent chain, and only if that finds nothing,
across into the main working tree of a linked worktree met on the way, reached
by reading `gitdir:` out of the `.git` FILE and following `commondir`. No
subprocess — a PreToolUse hook runs before every Bash call and must not fork to
answer this.

The search still starts at the working directory and never at `__file__`,
deliberately asymmetric with `issue.store_root` and `_gitea.PAYLOAD_ROOT`.
Where an installation keeps its files is a fact about the installation; whose
login a project runs under is a fact about the project, and a plugin pointed at
somebody else's tree must not answer that from its own directory. pin.py says
so in as many words, so the next reader does not "fix" the inconsistency.

Two consequences fall out of it. `/tea:auth` no longer has any reason to run
inside a worktree, so no second pin lands in a directory that is deleted with
the branch — the skill now says to write it beside the common `.git`. And the
scripts can run where the work is: the workaround the bug forced, cwd in the
main checkout, made push.py send that checkout's branch as `ref`, which is the
one thing `branch:` exists to record.

tests/test_login_pin.py holds both halves: the hop against a hand-built layout
and against a real `git worktree add`, a run from the worktree finding the
login, no pin anywhere still erroring, the scripts' own directory not becoming
a source, `ref` coming out as the worktree's branch, and the hook and a script
answering the same directory alike. Two mechanical checks keep the callers from
growing a second copy of the walk. Three existing fixtures now copy
skills/auth/scripts, which the transport imports.

Refs #24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:14:18 +05:00
naudachu a2e9a88186 feat: follow dependencies on every pull by default
`depends:` was filled and blockers were pulled only under `--deps`, so the
plain `pull.py <n>` — the only way to get a pushed issue back — answered
with a file whose graph was empty and an `issue_tree.py` that drew it as a
root with no blockers. The edge was not lost, but it was not asked for, and
it cannot be recovered locally: `map.from_api` writes slugs into the
`## Depends on` prose and never `#N`, so Gitea's native graph is the only
source there is.

A pull now returns the unit of work — the issue and what blocks it. `--deps`
stays accepted and does nothing, so existing calls and /tea:sync's tables
keep working; `--no-deps` is the way out and spends no request on either
half.

The cost is accepted and stated rather than hidden. The native links are now
fetched ONCE per issue instead of twice (they both fill `depends:` and steer
the walk), and only for an issue that lands in the store — a closed one that
filter mode drops no longer drags its blockers in behind it. That makes the
number quotable, and pull.py's docstring quotes it: a milestone of 50 open
issues costs one list request plus 50, where it used to cost one. In filter
mode a blocker no filter selected still lands in the store and still sits
outside `--limit`, deliberately, and both are documented; the exception is a
closed blocker, dropped like any other closed issue with the edge to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:41:27 +05:00
naudachu 627df76812 test: keep the payload root out of the developer's tree
One test stubs the transport a layer below `api()` — at `subprocess`, to
exercise the path a 422 really takes — so it reaches the real payload
write. That used to land in the test's own temp store, because the caller
named the directory; now the directory is `_gitea.PAYLOAD_ROOT`, resolved
from the module's location, and the file appeared in the developer's
`tmp/payload/`.

`StoreTestCase` patches `PAYLOAD_ROOT` to its fixture alongside the other
seams, and the rule in AGENTS.md gains the third directory a test must
not write to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:32:35 +05:00
naudachu 2f82b501bd feat: evict closed issues from the local store
The store is a working set, not an archive. Until now nothing removed a
closed issue from it: #10 put a filter on the write and said so explicitly
("existing store files are not cleaned"), and the migration was never
anybody's job. The only way out was rm past every script, followed by
rebuilding INDEX.md by hand.

issue_evict.py removes <id>.md and every sidecar under that slug for an
issue that is state: closed AND carries an origin: naming a tracker, then
rebuilds INDEX.md. --dry-run prints and writes nothing at all.

Two conditions, and the second one is the whole safety argument. An
origin: local issue IS the work — there is no other copy — so it is never
evicted, in any state, not even when named on the command line: it is
reported and kept. The only files that go are ones whose own metadata says
pull.py <n> brings them back, which is the trade push.py already makes
when it drops a file the tracker just confirmed.

The command lives in the domain layer, and the layering rule decides that
rather than convenience: state: and origin: are domain fields and the
answer is already on disk, so eviction needs no network, no login and no
tea. The domain also gains issue.slug_files — every file the store holds
under one slug, which is all_ids' "a slug has no dot in it" read the other
way round, and lets the domain remove an issue completely without learning
what a comment thread is.

skills/sync/scripts/evict.py is the bridge form, and it exists because a
local state: is only as fresh as the last pull: an issue closed in the web
UI still reads open here. It refreshes state: from Gitea, 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:
every candidate's state is fetched before anything is removed, each answer
must be an object carrying the number asked about and a state the domain
recognizes (confirmed_state, the counterpart of confirmed_number), and a
failed or unconfirmed call evicts nothing — not even the candidates whose
answers had already arrived, and no refreshed state: is written back
either. A candidate is an issue with a gitea: handle; origin: local has
none, is never asked about, and is never removed.

.remote.json is deliberately not pruned. It is the number -> slug ledger,
its entries are supposed to outlive the files they name, and an evicted
issue is in exactly the state a pushed one is.

AGENTS.md gains the rule the tracker side never wrote down: pull by number
fetches an issue in any state — an address is not a query. Eviction does
not revoke it, so a closed issue pulled after a cleanup is on disk again,
and that is the tracker answering what it was asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:28:13 +05:00
naudachu 9679e2c000 feat: close issues through a script
Closing was the last regular tracker operation with no script behind it.
The only way to move state: was a raw `tea api -X PATCH` against
repos/OWNER/REPO/issues/N with a hand-written body, which spells out the
owner, the repo and the request shape — the three things _gitea.py exists
to hide — and which needs a Bash(tea api *) permission wide enough to
cover -X DELETE on the repository.

close.py takes explicit ids, one or many, as a local slug or as any key
form pull.py accepts (42, #42, owner/repo#42, a URL). A slug resolves
through its gitea: field while the file is there and through .remote.json
after push has dropped it, so an issue with no local copy is still
closeable by name. --reopen is the same run backwards.

State only: the payload carries state and nothing else. Closing is not an
edit; editing stays pull -> change -> push --update. No --milestone and
no --label either — which issues are finished is a judgement about
content, and this only carries one out, one named id at a time.

An origin: local issue is refused: it is not in the tracker, so there is
no state there to change, and the error names the id rather than quietly
editing one field of a local file. Every argument is resolved before
anything is sent, so a typo in the third id cannot leave the first two
closed, and one run addresses one repo — a key that names its own is sent
there instead of to whatever repo the CWD happens to be in.

The local file is written only after the tracker confirmed this write: an
object carrying the number that was PATCHed, in the state that was asked
for (close.confirmed). A non-2xx, a transport that would not run, an
answer for another issue, a 200 that still says open — the run stops and
the file is byte for byte what it was. --dry-run prints the same lines,
makes no request at all and needs no pinned login.

tea-runner rule 4 narrows accordingly: closing was forbidden because
nothing but a raw call could do it, not because it is dangerous. It may
now close the ids the caller named, and no others; deleting and retitling
stay forbidden.

tests/test_close.py stubs the transport at _gitea.api and, for the
non-2xx path, one layer lower at _gitea.subprocess so a CLI that exits 1
is proved end to end. 286 tests, no network, no tmp/issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:27:22 +05:00
naudachu 596cf853e8 fix: keep request payloads out of the issue store
`labels.py` handed `_gitea.api` the issue store as a place to put the
request file, and on a checkout without a store that quietly created
`tmp/issues/.payload/`. Bootstrapping a repository's labels touches no
issue at all, so the one rule the store has — nothing materializes it as
a side effect of a write — was broken by an operation that has no
business knowing the store exists.

Where a request body goes was never the caller's decision to make. It is
now the transport's: `tmp/payload/`, resolved from `_gitea.py`'s own
location the way both domains resolve theirs, so every caller — sync and
wiki alike — writes to one directory whatever it was invoked from, and
`out_root` is gone from `api`, `add_dependency` and all six call sites.
The directory is created by the first write of a run and not before: a
`--dry-run` leaves nothing behind. `tmp/` is already gitignored.

The name carries the distinction the old path lost. A store holds the
only copy of something; this holds debris kept for a retry or a
post-mortem, and deleting it costs nothing. A dotdir sitting among an
issue's files claimed otherwise, and `ls tmp/issues` started lying about
what existed.

tests/test_payload_root.py runs the real `labels.py` in a throwaway repo
against a fake `tea` on PATH: no store appears, the payloads land in
tmp/payload/, a dry run writes nothing, and a run from a subdirectory
still resolves to the repo root. Two source checks keep the callers from
drifting apart again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:25:33 +05:00
naudachu bea3735e47 fix: apply --limit to the write, not to the selection
pull.py's docstring said "the limit is on the write, not on the
selection", and the code did the opposite: `list_issues` truncated the
payload list to `limit`, and pull.py dropped the closed ones after that.
A milestone whose first issues are closed therefore answered `--limit 20`
with twelve files, and the only statement about the behavior anywhere was
the false one.

The limit now counts what the run leaves in the store. `list_issues`
takes a `keep` predicate, pages keep arriving until `limit` payloads have
satisfied it, and the ones that did not are still returned — they were
enumerated, and pull.py still reports them as "N closed, not stored".
What `keep` means stays the caller's business; the transport only counts.
pull.py hands it `lands_in_store`, which is the same test the walk itself
applies: a closed issue counts only when the store already has it, since
that one is refreshed rather than dropped.

Pagination is the other half, and it cuts both ways. `paginate` is now a
thin wrapper over a new `pages` generator, so the page after the one that
fills the budget is never requested. In the other direction "fetch until
N are kept" is "fetch the whole tracker" on a filter that matches mostly
closed issues, so a keep-bounded read scans at most PAGE_SLACK times the
pages the limit would need if nothing were dropped, then warns on stderr
and returns short. Raising --limit raises that ceiling with it. --deps is
outside the count: a dependency is followed because an issue named it.

remote.py keeps the old meaning and now says so in as many words — it
writes nothing, so there is no write for a limit to bound, and its
--limit caps the listing, closed issues included. Same flag, two jobs,
documented in both scripts and in the skill's command table.

Also refuses `--limit 0` instead of dividing by the page size and
raising ZeroDivisionError.

tests/test_pull_limit.py stubs the transport with a fake that serves
`page=`/`limit=` itself, so the request pattern is observed rather than
assumed: exactly N files out of a half-closed selection, the second page
fetched and the third not, the scan stopping at the budget with a
warning, and remote.py's listing unchanged. 251 tests, no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:23:12 +05:00
84 changed files with 8994 additions and 2873 deletions
+7 -2
View File
@@ -1,13 +1,18 @@
{ {
"name": "tea", "name": "claude-skills",
"owner": { "owner": {
"name": "naudachu" "name": "naudachu"
}, },
"plugins": [ "plugins": [
{ {
"name": "tea", "name": "tea",
"source": "./", "source": "./plugins/tea",
"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." "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."
},
{
"name": "tdl",
"source": "./plugins/tdl",
"description": "Three Dots Labs Go conventions as an enforceable rule set: /tdl:audit scans a Go project against 63 CQRS/DDD/Clean-Architecture rules and reports violations by severity, or scaffolds new services, handlers, entities, repositories and Watermill adapters from templates that already follow them."
} }
] ]
} }
-10
View File
@@ -1,10 +0,0 @@
{
"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.",
"version": "2.2.0",
"author": {
"name": "naudachu"
},
"license": "MIT",
"keywords": ["gitea", "cli", "git", "issues", "wiki", "login-guard"]
}
-185
View File
@@ -1,185 +0,0 @@
# AGENTS.md
## Project goals
1. **Unify and systematize issue workflow** for the development team with
minimal context usage. Issue operations are wrapped in scripts so agents
spend tokens on the task, not on re-deriving commands and formats.
2. **Keep the tracker out of the work.** An issue is a unit of work first and a
Gitea row second. The two are separate layers, and the first one does not
know the second exists.
3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of
direct ad-hoc calls wherever possible. Scripts give deterministic,
reviewable behavior; the `tea-guard` hook enforces that every `tea`
invocation runs under the operator-pinned login.
## Layers
The hard rule of this repo. Two domains, two bridges, one transport, and
knowledge flows one way only:
```
skills/issue DOMAIN what an issue is: format, validation, dependency graph
skills/page DOMAIN what a page tree is: title <-> path, order, the index
▲ offline — no tracker, no network, stdlib imports only
│ imports
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
_gitea.py login pin, tea api, pagination, filters
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
transport is _gitea.py — there is no second one
skills/use REFERENCE tea CLI docs for everything that is not an issue
skills/auth IDENTITY pin the login the whole tracker side runs under
│ calls
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
```
A domain never imports its bridge, and the two domains do not import each
other: delete `skills/sync` and issues still work, delete `skills/wiki` and page
trees still work, delete either domain and the other is untouched. The check is
mechanical — every import under a domain's `scripts/` is stdlib, and
`subprocess` is not among them:
```bash
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
```
If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
`content_base64`) shows up in a domain layer, it is in the wrong place.
## Repo layout
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
- `references/format.md` — canonical issue format; single source of truth
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
taxonomy, dependency graph, body checkboxes
- `scripts/issue_new.py` — create a local issue from its type template
- `scripts/issue_check.py` — validate against the format
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
substring, changing exactly one character of the file
- `scripts/issue_tree.py` — draw the dependency graph
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
label ids, the remote-id map
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
- `skills/page` — a discussion's artifacts as a page tree (`/tea:page`),
entirely offline
- `references/pages.md` — canonical page-tree format; single source of truth
- `scripts/page.py` — domain module: title ↔ path, ordering, the manifest,
importing a directory of markdown, the index
- `scripts/page_import.py` — copy a directory of markdown into a space,
titling every file
- `scripts/page_index.py` — write the table-of-contents page
- `scripts/page_ls.py` — the tree, the titles, one sync-state tag per page
- `skills/wiki` — move page trees between a local space and a Gitea wiki
(`/tea:wiki`)
- `scripts/wikimap.py` — md ↔ Gitea wiki JSON, pure, no I/O
- `scripts/wiki_ls.py`, `wiki_pull.py`, `wiki_push.py` — transport is
`skills/sync/scripts/_gitea.py`
- `skills/use``tea` CLI reference for everything that is not an issue
(`/tea:use`); `references/tea/` holds the command docs
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
returns a compact receipt. Delegate batches (bulk pull, push a named set,
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
and no `Write`, may not `--force`, and may not decide what an issue says.
Delegating a single call costs more than running it inline — the win is the
loop, the retry, and the error triage.
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
that don't use the pinned login; `agents-sync` keeps every directory canonical
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
- `tests/` — stdlib `unittest`, no third-party anything
## Tests
```bash
python3 -m unittest discover -s tests -v
```
Plain `unittest`; no pytest, no dependencies — the scripts under test are
stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
packages, so a test that needs the domain module imports it with
`sys.path.insert`.
**A test never touches `tmp/issues/` or `tmp/wiki/`.** Anything that needs a
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
`.git` marker, a copy of the script layers, fixture issues or artifacts — and
runs the real scripts inside it as subprocesses. That is the only way to test
behavior that depends on where a script is run from, and it keeps the
developer's own store out of the blast radius.
## Local issue store
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
store.** An `origin: local` issue lives here and nowhere else — this file *is*
the issue, and losing it loses the work. Anything with `origin: gitea` is a
**cache**: the tracker has it, this copy is a working copy, and it is deleted
the moment a push confirms the tracker is up to date.
One flat markdown file per issue, named by its slug, with one metadata field per
line so plain grep works without a parser.
- **The path is `<repo root>/tmp/issues`, resolved from `issue.py`'s own
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
whatever directory it is run from. An explicit `--out` overrides it and is
used exactly as typed; a relative `--out` stays relative to cwd.
- Nothing creates the store as a side effect of a write. Readers distinguish
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
and they say so on stderr.
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
Numbers live in the `gitea:` field.
- `origin: local` is a complete state, not a draft: an issue that never leaves
this machine is valid and finished. It is not a *durable* state, though —
pushing ends it, and the local file goes with it.
- **A successful push deletes the local file** (`<id>.md` and
`<id>.comments.md`), and prints the number and URL the issue now lives at.
`--update` too: one rule, no exception. What is in the store is what has not
left. Get it back with `pull.py <n>`.
- Deletion happens only after a confirmed tracker response and only after
`.remote.json` has been written. Network down, non-2xx, an answer that does
not carry the right number: the file stays and the run stops. A never-pushed
`origin: local` issue is never touched by any of this.
- The slug survives the round trip because it goes up in the body as
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
fresh clone, another machine — the file comes back under the same name and
every `depends:` that points at it still resolves.
- `.remote.json` is therefore no longer "an index over the files": it is the
local number → slug ledger, its entries outlive the files they name, and
nothing prunes them. It is still recoverable — from the markers in Gitea, not
from the files.
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
issue comes back at all.
- No drift tracking, and now nothing to track: there is no second copy to
diverge from. `synced:` tells you how old your working copy is.
## Local wiki cache
`tmp/wiki/<space>/` (gitignored) holds page trees — a discussion's artifacts,
organized. Same stance as the issue store, resolved the same way from
`page.py`'s own location, with the same `--out` rule.
- Identity is the **title**, and `/` inside it is the only hierarchy there is.
The Gitea wiki is flat: it escapes a title into one filename by rules of its
own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`).
- **`sub_url` is Gitea's address for a page and is never constructed.** It is
read back from the API and stored in `.pages.json`. One built by hand that is
almost right creates a second page instead of editing the first.
- **Never commit a subdirectory into a wiki's git repository.** Gitea does not
see it — the page exists on disk and nowhere in the API or the UI. Do not
clone the wiki repo to work in; use the scripts.
- A title is a decision, not a derivation. A re-import replaces bodies and
keeps titles, so editing a heading cannot silently rename a published page.
`--retitle` opts in, and the rename reaches the wiki on the next push.
- A page with no `sub_url` has never been published — a complete state, the way
`origin: local` is for an issue. **The parallel stops at the push**: a pushed
page stays on disk, a pushed issue does not.
- Change detection is one hash (`pushed`). Pulling overwrites; pushing is
additive and never deletes — the one place the two domains deliberately
disagree, because a page tree is worked on locally and an issue is not.
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
`_gitea.py`.
+41 -151
View File
@@ -1,173 +1,63 @@
# tea — Claude Code plugin for the Gitea CLI # claude-skills — a Claude Code plugin marketplace
A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforces a hard rule: every `tea` command runs under the login **the operator chose**, never one Claude picked. One repository, one marketplace, several plugins. Register it once and install
whichever pieces you want; each plugin is independent and carries its own
## What it ships manifest, docs, and tests.
| Piece | What it does |
|---|---|
| `/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: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,
and knowledge flows one way:
```
skills/issue DOMAIN what an issue is: format, validation, dependency graph
▲ offline — no tracker, no network, stdlib only
│ imports
skills/sync BRIDGE md <-> Gitea JSON, then over the wire
│ calls
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
```
Delete `skills/sync` and the domain layer keeps working — issues that live only
on your machine are first-class, not drafts waiting to be uploaded. That is the
point of the split: you can plan, write, validate, and track work without a
tracker, and publish only what you choose to.
## Prerequisites
- **Claude Code** — CLI, desktop app, or IDE extension
- **Python 3** — required by the `tea-guard` hook (`python3` must be on `$PATH`)
- **`tea`** — Gitea's official CLI. Install with `brew install tea` (macOS) or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases)
- At least one login configured: `tea logins add` (interactive — run it in a terminal, not via Claude)
## Installation ## Installation
This is a Claude Code plugin — install it through the plugin marketplace, not by hand-editing `settings.json`.
1. Register this repo as a marketplace:
```
/plugin marketplace add https://git.noodles.cam/claude-skills/tea.git
```
Already have a local clone? Point at the directory instead:
```
/plugin marketplace add /path/to/tea
```
2. Install the plugin:
```
/plugin install tea@tea
```
The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later.
> The marketplace registration is written to `extraKnownMarketplaces` and the plugin to `enabledPlugins` in your settings automatically — you don't edit those by hand. There is **no** top-level `"plugins"` settings key; if you've added one from older instructions, remove it.
## First use
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to `.claude/settings.local.json` and takes effect immediately — no restart needed.
``` ```
/tea:auth /plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
``` ```
After that, just ask Claude to do something with issues or Gitea — it loads the Working from a local clone? Point at the directory instead:
right skill automatically. `/tea:auth` is only needed for the tracker side;
`/tea:issue` works without any login at all.
## How the login guard works ```
/plugin marketplace add /path/to/marketplace
```
Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. Then install what you need:
Claude is **blocked** from: ```
- running `tea` without `--login` at all /plugin install tea@claude-skills
- naming a login itself (e.g. `--login myaccount`) /plugin install tdl@claude-skills
- using any variable other than `$GITEA_LOGIN` ```
This prevents silent fallback to the machine's default login (often a personal account) when working in a project that belongs to a different identity. Use `/plugin` to enable, disable, or update them later.
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data. ## What ships here
## The tea-runner agent | Plugin | Commands | What it does |
|---|---|---|
| [`tea`](plugins/tea) | `/tea:auth` `/tea:issue` `/tea:sync` `/tea:use` | Gitea issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; a PreToolUse hook blocks any `tea` command that would run under a login Claude picked instead of the operator |
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against 63 CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on ## Layout
Haiku that does the second half in its own context and hands back a receipt —
what ran, what it touched, what failed, verbatim.
Delegate a **batch**: pull a milestone and rebuild the index, push the three
issues you just wrote, bootstrap the label set, post a comment from a file you
prepared. Spawning it for a single `pull.py 42` costs more than running the
command yourself; the saving is in the loop, the retry, and reading somebody
else's stderr.
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
type, a failed validation, an unpushed dependency come back as a question, not
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
session — the pinned login is enforced on every call it makes.
## Project layout
``` ```
.claude-plugin/ .claude-plugin/
plugin.json plugin manifest marketplace.json the catalog — one entry per plugin, source is a
marketplace.json marketplace catalog (makes `/plugin install` work) path into plugins/
agents/ plugins/
tea-runner.md subagent (Haiku) that executes the scripts tea/
hooks/ .claude-plugin/plugin.json
hooks.json registers the PreToolUse hook agents/ hooks/ skills/ tests/
tea-guard.sh the guard (Python 3, no deps) README.md AGENTS.md
skills/ tdl/
auth/SKILL.md /tea:auth skill .claude-plugin/plugin.json
issue/ /tea:issue — the domain layer, offline skills/
SKILL.md
references/format.md canonical issue format (identity, types, templates)
scripts/ Python 3, stdlib only, no network:
issue.py domain module: slug identity, parse/render,
validation, taxonomy, dependency graph,
body checkboxes
issue_new.py create a local issue from its type template
issue_check.py validate against the format
issue_ac.py list the body's checkboxes; tick one
issue_tree.py draw the dependency graph
issue_index.py rebuild tmp/issues/INDEX.md
sync/ /tea:sync — the bridge to Gitea
SKILL.md
scripts/
map.py md <-> Gitea JSON, pure functions, no I/O
_gitea.py transport: login pin, tea api, pagination, filters
pull.py Gitea -> tmp/issues/
push.py tmp/issues/ -> Gitea, then drops the local file
remote.py discovery listing to stdout
comment.py post or edit a comment
use/ /tea:use — tea CLI reference (non-issue entities)
SKILL.md
references/tea/ command docs
``` ```
## Local issue store A plugin's root is its directory under `plugins/`, so `${CLAUDE_PLUGIN_ROOT}`
resolves inside it and every path a plugin uses stays relative to itself.
Adding a plugin means adding a directory here plus one entry in
`marketplace.json` — nothing else in the repo needs to know about it.
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata ## Development
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
a parser.
An `origin: local` file **is** the issue — the store, and the only copy. `tea` has a test suite; run it from its own directory so the tests resolve
Anything with `origin: gitea` is a working copy of something the tracker their root correctly:
already has, and it is deleted as soon as a push confirms the tracker is up to
date:
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers ```
live in a `gitea:` field. cd plugins/tea && python3 -m unittest discover -s tests
- `origin: local` is a complete state. An issue that never leaves your machine ```
is valid and finished — but it is not permanent: pushing ends it.
- **A successful push deletes the local file** (`--update` too) and prints the
number and URL it now lives at. Only after a confirmed response: a failed
call leaves the file exactly where it was. Get it back with `pull.py <n>` —
same slug, same `depends:`, even after a rename in Gitea.
- Pulling overwrites the body: a fetch, not a merge. It is also how a pushed
issue comes back.
- Nothing tracks drift, and there is no second copy to drift. A file that is
still here has not been pushed.
-133
View File
@@ -1,133 +0,0 @@
#!/usr/bin/env python3
"""
tea-guard — PreToolUse(Bash) hook for the `tea` plugin.
Enforces, deterministically, the one rule prose cannot: every `tea` command
that touches Gitea runs under the login the OPERATOR pinned — never one Claude
chose. It does this by *resolving and rewriting* the command rather than just
checking it:
Claude must write: tea ... --login "$GITEA_LOGIN" ...
The guard rewrites: tea ... --login <operator-pinned-login> ...
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.
Rules:
- not a `tea` command ............................. allow (passthrough)
- tea logins list/ls, tea --version/--help ........ allow (no identity used)
- no --login / -l ................................. BLOCK
- --login <literal> or --login "$OTHER_VAR" ....... BLOCK (Claude may not pick)
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
rewrite; exit 2 + stderr to block.
"""
import sys, os, re, json, shlex
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
def block(msg):
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
sys.exit(2)
def allow_passthrough():
# exit 0 with no stdout → tool runs unchanged
sys.exit(0)
def rewrite(tool_input, new_cmd, note):
updated = dict(tool_input)
updated["command"] = new_cmd
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": updated,
"additionalContext": note,
}
}))
sys.exit(0)
def find_pin(start_dir):
"""Walk up from start_dir; return (login, path) from the first
.claude/settings.local.json that carries a non-empty env.GITEA_LOGIN."""
try:
d = os.path.abspath(start_dir or ".")
except Exception:
return None, None
while True:
p = os.path.join(d, ".claude", "settings.local.json")
if os.path.isfile(p):
try:
with open(p) as f:
data = json.load(f)
v = (data.get("env") or {}).get("GITEA_LOGIN")
if isinstance(v, str) and v.strip():
return v.strip(), p
except Exception:
pass
parent = os.path.dirname(d)
if parent == d:
return None, None
d = parent
def main():
try:
payload = json.load(sys.stdin)
except Exception:
# Can't parse the hook payload — fail open for non-tea safety, but we
# can't even read the command, so don't block arbitrary Bash.
allow_passthrough()
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()
# 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):
allow_passthrough()
# Locate --login / -l and its value (logins never contain spaces).
m = re.search(r'(--login|(?<![\w-])-l)(\s+|=)(\S+)', 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)
start = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
pin, src = find_pin(start)
if not pin:
block('no login is pinned. Run /tea:auth to choose one (writes '
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
'the file at call time, so it takes effect with no restart.')
new_cmd = cmd[:m.start(3)] + shlex.quote(pin) + cmd[m.end(3):]
rewrite(tool_input, new_cmd,
'tea-guard: resolved --login -> %s (pinned in %s)' % (pin, src))
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
{
"name": "tdl",
"description": "Three Dots Labs Go conventions as an enforceable rule set: /tdl:audit scans a Go project against 63 CQRS/DDD/Clean-Architecture rules and reports violations by severity, or scaffolds new services, handlers, entities, repositories and Watermill adapters from templates that already follow them.",
"version": "1.0.0",
"author": {
"name": "naudachu"
},
"license": "MIT",
"keywords": ["go", "ddd", "cqrs", "clean-architecture", "watermill", "audit"]
}
+214
View File
@@ -0,0 +1,214 @@
---
name: audit
description: "Three Dots Labs Go style/pattern guide. Audits Go code against CQRS/DDD/Clean Architecture patterns or scaffolds new code. /tdl:audit [path] to audit, /tdl:audit scaffold <type> <name> to generate."
user-invocable: true
argument-hint: "[path] | scaffold <type> <name>"
---
# Three Dots Labs Go Architecture Auditor
You are a Go architecture auditor specializing in Three Dots Labs CQRS/DDD/Clean Architecture patterns. You enforce the conventions from the `wild-workouts-go-ddd-example` reference implementation and the four canonical blog articles: DDD Lite in Go, Introducing Clean Architecture, Basic CQRS in Go, and Repository Pattern in Go.
## Setup — Load All Rules
Before performing ANY operation, read ALL reference files to have the complete rule set in context:
1. Read `<skill-base-dir>/references/rules-architecture.md`
2. Read `<skill-base-dir>/references/rules-domain.md`
3. Read `<skill-base-dir>/references/rules-cqrs.md`
4. Read `<skill-base-dir>/references/rules-repository.md`
5. Read `<skill-base-dir>/references/rules-errors.md`
6. Read `<skill-base-dir>/references/rules-ports.md`
7. Read `<skill-base-dir>/references/rules-naming.md`
8. Read `<skill-base-dir>/references/rules-codestyle.md`
9. Read `<skill-base-dir>/references/rules-watermill.md`
Read all 9 files in parallel before proceeding.
## Argument Parsing
Parse the user's arguments:
- **No arguments** or **`audit`**: Run audit on current working directory
- **`<path>`** or **`audit <path>`**: Run audit on the specified path
- **`scaffold service <Name>`**: Generate full service skeleton
- **`scaffold command <Name>`**: Generate command handler file
- **`scaffold query <Name>`**: Generate query handler file
- **`scaffold entity <Name>`**: Generate domain entity file
- **`scaffold repo <Name>`**: Generate repository interface + memory implementation
- **`scaffold unified_server`**: Generate unified server with named components, OnShutdown, With* options
- **`scaffold watermill_router`**: Generate WithWatermillRouter option + publisher client
- **`scaffold event_handler <Name>`**: Generate event handler port (inbound Watermill adapter)
- **`scaffold event_publisher <Name>`**: Generate event publisher adapter (outbound Watermill adapter)
If arguments don't match any pattern, show usage help.
---
## Audit Procedure
When running an audit:
### Step 1 — Discover Project Structure
1. Find `go.mod` to determine the module path
2. Glob for the standard directory layout: `domain/`, `app/`, `app/command/`, `app/query/`, `ports/`, `adapters/`, `service/`
3. Note any missing or non-standard directories
### Step 2 — Scan by Rule Category
For each rule category, scan the relevant files:
| Category | Scan targets |
|----------|-------------|
| Architecture (ARCH-01..08) | Directory structure, all `.go` file imports, `service/`, `main.go` |
| Watermill (WM-01..10) | `main.go`, `server/watermill.go`, `client/watermill.go`, `ports/event.go`, `adapters/*event*.go`, `app/command/services.go` |
| Domain (DOM-01..09) | All files in `domain/` |
| CQRS (CQRS-01..10) | Files in `app/command/`, `app/query/`, `app/app.go` |
| Repository (REPO-01..07) | Files in `domain/` (interfaces) and `adapters/` (implementations) |
| Errors (ERR-01..05) | All files in `domain/`, error-related files |
| Ports (PORT-01..06) | Files in `ports/` |
| Naming (NAME-*) | All `.go` files — function names, type names |
| Code Style (STYLE-01..08) | All `.go` files, `_test.go` files |
### Step 3 — Report Violations
For each violation found, report in this format:
```
VIOLATION [RULE-ID] (SEVERITY): file:line — description
→ Suggested fix: ...
```
Severity levels:
- **CRITICAL**: Breaks core architecture rules (wrong dependency direction, exported domain fields, CRUD naming)
- **WARNING**: Deviates from best practices (missing decorators, no IsZero, missing factory)
- **INFO**: Minor style issues (import ordering, receiver naming)
### Step 4 — Summary
At the end, output:
```
═══ Audit Summary ═══
CRITICAL: N violations
WARNING: N violations
INFO: N violations
Conformance: X/63 rules passing
Top priorities:
1. [RULE-ID]: brief description of most impactful fix
2. [RULE-ID]: ...
3. [RULE-ID]: ...
```
---
## Scaffold Procedure
When generating code:
### Step 1 — Gather Context
1. Read `go.mod` to get the module path (`{{module}}`)
2. Detect existing directory structure
3. Determine proper package paths
### Step 2 — Read Template
Read the appropriate template from `<skill-base-dir>/templates/`:
| Type | Template file |
|------|--------------|
| `service` | `templates/service.md` |
| `command` | `templates/command.md` |
| `query` | `templates/query.md` |
| `entity` | `templates/entity.md` |
| `repo` | `templates/repo.md` |
| `unified_server` | `templates/unified_server.md` |
| `watermill_router` | `templates/watermill_router.md` |
| `event_handler` | `templates/event_handler.md` |
| `event_publisher` | `templates/event_publisher.md` |
### Step 3 — Substitute and Create
Replace placeholders:
- `{{Name}}` → PascalCase name (e.g., `ScheduleTraining`)
- `{{name}}` → camelCase name (e.g., `scheduleTraining`)
- `{{name_snake}}` → snake_case name (e.g., `schedule_training`)
- `{{module}}` → Go module path from go.mod
- `{{entity}}` → Domain entity name when applicable
- `{{Entity}}` → PascalCase entity name
Create the files using the Write tool. After creation, list what was created and any manual steps needed (e.g., updating `app.go`).
---
## Quick Rule Reference
| ID | Rule | Severity |
|----|------|----------|
| ARCH-01 | Standard directory layout: domain/, app/{command,query}, ports/, adapters/, service/ | CRITICAL |
| ARCH-02 | Dependency direction: domain ← app ← ports/adapters; domain imports NOTHING from app/ports/adapters | CRITICAL |
| ARCH-03 | Composition root isolation — only service/ knows concrete adapters and infra | CRITICAL |
| ARCH-04 | Dual constructor pattern — shared private wiring, prod + test constructors | WARNING |
| ARCH-05 | Cleanup function returned from NewApplication for resource lifecycle | WARNING |
| ARCH-06 | Server startup via callback — main.go provides handler, never configures internals | WARNING |
| ARCH-07 | Composition root must not own server lifecycle — no servers, listeners, signals in service/ | CRITICAL |
| ARCH-08 | Unified server with named components and OnShutdown — explicit shutdown ordering | WARNING |
| DOM-01 | All entity fields private (unexported) | CRITICAL |
| DOM-02 | Factory constructors: New{Type}(...) (*Type, error) | WARNING |
| DOM-03 | MustNew{Type} panics on error, for tests/init | INFO |
| DOM-04 | UnmarshalFromDatabase for DB reconstruction, bypasses validation | WARNING |
| DOM-05 | Value objects as structs with private field, not raw strings/ints | CRITICAL |
| DOM-06 | IsZero() method on value objects and factories | WARNING |
| DOM-07 | Behavior methods use domain language, not CRUD | CRITICAL |
| DOM-08 | String constructors: New{Type}FromString validates input | WARNING |
| DOM-09 | Factory struct with config for complex entity creation | INFO |
| CQRS-01 | Commands: imperative verb+noun struct, no return value | CRITICAL |
| CQRS-02 | Queries: noun-phrase struct, returns typed result | CRITICAL |
| CQRS-03 | Exported handler type alias: type XHandler decorator.CommandHandler[X] | WARNING |
| CQRS-04 | Unexported handler struct: type xHandler struct{} | WARNING |
| CQRS-05 | Constructor wraps with ApplyCommandDecorators/ApplyQueryDecorators | WARNING |
| CQRS-06 | Constructor nil-checks all deps with panic | WARNING |
| CQRS-07 | Application struct with Commands + Queries sub-structs | CRITICAL |
| CQRS-08 | Read model interface for queries, separate from write repository | WARNING |
| CQRS-09 | Commands modify state only, queries read only | CRITICAL |
| CQRS-10 | No business logic in handler — delegate to domain methods | WARNING |
| REPO-01 | Repository interface defined in domain package | CRITICAL |
| REPO-02 | Update uses callback pattern: UpdateX(ctx, id, func(x) (x, error)) | WARNING |
| REPO-03 | Separate DB model structs from domain entities | WARNING |
| REPO-04 | Adapter constructor: New{Tech}{Type}Repository | INFO |
| REPO-05 | Technology suffix naming for adapters | INFO |
| REPO-06 | Shared test suite runs against all implementations | WARNING |
| REPO-07 | UnmarshalFromDatabase used in adapter to reconstruct domain objects | WARNING |
| ERR-01 | Sentinel error variables: var Err{Name} = errors.New(...) | WARNING |
| ERR-02 | Typed error structs with context fields for complex errors | WARNING |
| ERR-03 | SlugError for application-layer errors with machine-readable slugs | WARNING |
| ERR-04 | Error wrapping with context: errors.Wrap(err, "...") | INFO |
| ERR-05 | No bare fmt.Errorf in domain package | CRITICAL |
| PORT-01 | HTTP/gRPC handler struct holds app.Application | WARNING |
| PORT-02 | Error mapping via httperr.RespondWithSlugError or status.Error | WARNING |
| PORT-03 | Auth extracted from context, not parsed in handler | WARNING |
| PORT-04 | No business logic in port handlers — only marshal/unmarshal + delegate | CRITICAL |
| PORT-05 | Response model mapping functions separate from handlers | INFO |
| PORT-06 | No Unimplemented embedding in gRPC servers — compile-time compliance | CRITICAL |
| STYLE-01 | Import groups: stdlib, blank line, external packages | INFO |
| STYLE-02 | Pointer receivers for mutation, value for reads | INFO |
| STYLE-03 | t.Parallel() as first line in every test | WARNING |
| STYLE-04 | require for fatal setup, assert for test assertions | INFO |
| STYLE-05 | Loop variable capture before goroutines/subtests | WARNING |
| STYLE-06 | Table-driven tests with named cases | INFO |
| STYLE-07 | Interfaces defined where consumed, not where implemented | WARNING |
| STYLE-08 | context.Context as first parameter for I/O methods | WARNING |
| WM-01 | Router factory via callback — same pattern as gRPC/HTTP | CRITICAL |
| WM-02 | Publisher factory returns (Publisher, Close, Error) triple | CRITICAL |
| WM-03 | Event handlers live in ports/ — same as HTTP/gRPC handlers | CRITICAL |
| WM-04 | Event publisher adapter implements domain interface | WARNING |
| WM-05 | Topic naming uses domain language with dot notation | WARNING |
| WM-06 | Event structs live in ports/ or adapters/, not domain/ | INFO |
| WM-07 | Watermill middleware in server factory only | WARNING |
| WM-08 | Publisher cleanup in composition root cleanup function | WARNING |
| WM-09 | Named components replace SERVER_TO_RUN switch | INFO |
| WM-10 | No sync side effects replaced by fire-and-forget without saga | CRITICAL |
@@ -0,0 +1,480 @@
# Architecture Rules (ARCH-01..08)
## ARCH-01: Standard Directory Layout (CRITICAL)
Every service MUST follow this directory structure:
```
<service>/
├── domain/<aggregate>/ # Pure business logic, entities, value objects, repository interfaces
├── app/ # Application struct (app.go) with Commands + Queries
│ ├── command/ # Write use cases (command handlers)
│ └── query/ # Read use cases (query handlers + read model interfaces)
├── ports/ # Inbound adapters: HTTP handlers, gRPC servers, CLI
├── adapters/ # Outbound adapters: repository implementations, external clients
└── service/ # Composition root: wires all dependencies together
```
**Check procedure:**
1. Glob for these directories relative to the service root
2. Flag any missing standard directories
3. Flag any non-standard directories at the same level (e.g., `controllers/`, `models/`, `handlers/`)
4. Multiple aggregates can exist under `domain/` as sub-packages (e.g., `domain/hour/`, `domain/training/`)
**Reference (wild-workouts):**
```
internal/trainer/
├── domain/hour/
├── app/
│ ├── command/
│ └── query/
├── ports/
├── adapters/
└── service/
```
---
## ARCH-02: Dependency Direction (CRITICAL)
Dependencies MUST flow inward only: `ports/adapters → app → domain`
The domain layer MUST NOT import from:
- `app/`, `app/command/`, `app/query/`
- `ports/`
- `adapters/`
- Any external infrastructure package (database drivers, HTTP frameworks, etc.)
The app layer MUST NOT import from:
- `ports/`
- `adapters/`
**Check procedure:**
1. For every `.go` file in `domain/`, scan import statements
2. Flag any import that references `app/`, `ports/`, `adapters/`, or the service's own non-domain packages
3. For every `.go` file in `app/`, scan imports for `ports/` or `adapters/`
4. Domain MAY import standard library and pure utility packages
**Allowed domain imports:**
- Standard library (`context`, `time`, `errors`, `fmt`, `strings`, etc.)
- Pure value libraries (e.g., `github.com/google/uuid`)
- NOT: database drivers, HTTP routers, gRPC, logging libraries
---
## ARCH-03: Composition Root Isolation (CRITICAL)
All dependency wiring MUST happen exclusively in `service/`. The composition root is the **only** place that knows about concrete adapter types, infrastructure clients, and how dependencies connect.
**`main.go`** MUST only:
1. Initialize cross-cutting concerns (logging)
2. Call `service.NewApplication()`
3. Wire ports (pass `app.Application` to port constructors)
4. Start the server
`main.go` MUST NOT import `adapters/`, create infrastructure clients, or instantiate command/query handlers directly.
**Check procedure:**
1. Scan `main.go` imports — flag any reference to `adapters/`, database drivers, or external service clients
2. Scan all files outside `service/` — flag any call to adapter constructors (e.g., `adapters.New*`)
3. Verify `service/` returns `app.Application`
**Correct:**
```go
// main.go — only knows about service and ports
func main() {
logs.Init()
ctx := context.Background()
app, cleanup := service.NewApplication(ctx)
defer cleanup()
server.RunHTTPServer(func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
})
}
```
**Wrong:**
```go
// main.go — VIOLATION: wiring infrastructure directly
func main() {
client, _ := firestore.NewClient(ctx, os.Getenv("GCP_PROJECT")) // VIOLATION
repo := adapters.NewFirestoreRepository(client) // VIOLATION
handler := command.NewScheduleTrainingHandler(repo, logger, mc) // VIOLATION
// ...
}
```
---
## ARCH-04: Dual Constructor Pattern for Testability (WARNING)
The composition root MUST provide two constructors sharing a single private wiring function:
1. **`NewApplication(ctx) (app.Application, func())`** — production constructor, creates real infrastructure
2. **`NewComponentTestApplication(ctx) app.Application`** — test constructor, injects mocks/stubs
Both MUST delegate to a **private** `newApplication(...)` that accepts dependencies as interfaces, so the real vs test paths only differ in what they pass in.
This ensures:
- Test mocks never leak into production wiring
- All wiring logic is shared — no drift between prod and test setups
- The private function signature documents the full set of external dependencies
**Check procedure:**
1. Look for exported `NewApplication` and `NewComponentTestApplication` in `service/`
2. Verify both call the same unexported function
3. The unexported function MUST accept dependencies as interfaces, not concrete types
**Correct:**
```go
// service/service.go
func NewApplication(ctx context.Context) (app.Application, func()) {
trainerClient, closeTrainer, err := client.NewTrainerClient()
if err != nil { panic(err) }
trainerService := adapters.NewTrainerGrpc(trainerClient)
return newApplication(ctx, trainerService),
func() { _ = closeTrainer() }
}
func NewComponentTestApplication(ctx context.Context) app.Application {
return newApplication(ctx, TrainerServiceMock{})
}
func newApplication(ctx context.Context, trainerService command.TrainerService) app.Application {
// shared wiring logic — accepts interfaces, not concrete types
repo := adapters.NewFirestoreRepository(client)
return app.Application{ /* ... */ }
}
```
**Wrong:**
```go
// VIOLATION: separate wiring paths, no shared private function
func NewApplication(ctx context.Context) app.Application {
repo := adapters.NewFirestoreRepository(client)
return app.Application{
Commands: app.Commands{
ScheduleTraining: command.NewScheduleTrainingHandler(repo, logger, mc),
},
}
}
func NewTestApplication() app.Application {
repo := NewMockRepo() // VIOLATION: duplicated wiring, can drift
return app.Application{
Commands: app.Commands{
ScheduleTraining: command.NewScheduleTrainingHandler(repo, logger, mc),
},
}
}
```
---
## ARCH-05: Cleanup Function for Resource Lifecycle (WARNING)
When the composition root creates resources that require cleanup (connections, clients, subscriptions), `NewApplication` MUST return a cleanup function alongside the application. The caller owns the lifecycle via `defer`.
This ensures:
- Resources are released even on panic
- `main.go` doesn't need to know *what* to clean up — just *that* it must
- Adding new infrastructure only changes `service/`, not `main.go`
**Check procedure:**
1. If `NewApplication` creates closeable resources (clients, connections), it MUST return `func()`
2. `main.go` MUST call `defer cleanup()` immediately after receiving it
3. The cleanup function MUST NOT be ignored (assigned to `_`)
**Correct:**
```go
// service/service.go
func NewApplication(ctx context.Context) (app.Application, func()) {
trainerClient, closeTrainer, err := client.NewTrainerClient()
if err != nil { panic(err) }
usersClient, closeUsers, err := client.NewUsersClient()
if err != nil { panic(err) }
return newApplication(ctx, adapters.NewTrainerGrpc(trainerClient), adapters.NewUsersGrpc(usersClient)),
func() {
_ = closeTrainer()
_ = closeUsers()
}
}
// main.go
app, cleanup := service.NewApplication(ctx)
defer cleanup()
```
**Wrong:**
```go
// VIOLATION: caller must know internals to clean up
func NewApplication(ctx context.Context) (app.Application, *firestore.Client, *grpc.ClientConn) {
// ...
}
// VIOLATION: cleanup responsibility leaks into main
app, fsClient, conn := service.NewApplication(ctx)
defer fsClient.Close() // main.go shouldn't know about Firestore
defer conn.Close() // main.go shouldn't know about gRPC
```
---
## ARCH-06: Server Startup via Callback (WARNING)
Server startup MUST be delegated to a shared `server.Run*Server()` function. `main.go` provides **only the application handler** via a callback. It MUST NOT configure server internals: middleware, routing, listening address, or transport-level concerns.
This ensures:
- Middleware stack (auth, logging, recovery, CORS, security headers) is consistent across all services
- Adding or changing middleware is a single change, not per-service
- `main.go` remains a thin orchestrator: init → wire app → provide handler → run
**Check procedure:**
1. `main.go` MUST call a shared `Run*Server()` function as the final blocking call
2. The callback passed to `Run*Server()` MUST only construct the handler from port constructors — no middleware setup, no router configuration, no listener creation
3. `main.go` MUST NOT import server infrastructure packages (e.g., `net/http.ListenAndServe`, `net.Listen`, middleware libraries)
**Correct:**
```go
// main.go — provides handler, delegates everything else
func main() {
logs.Init()
ctx := context.Background()
app, cleanup := service.NewApplication(ctx)
defer cleanup()
server.RunHTTPServer(func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
})
}
```
**Wrong:**
```go
// VIOLATION: main.go configures server internals
func main() {
app, cleanup := service.NewApplication(ctx)
defer cleanup()
router := chi.NewRouter()
router.Use(middleware.Logger) // VIOLATION: middleware in main
router.Use(middleware.Recoverer) // VIOLATION: middleware in main
router.Mount("/api", ports.NewHttpServer(app))
http.ListenAndServe(":8080", router) // VIOLATION: listening in main
}
```
---
## ARCH-07: Composition Root Must Not Own Server Lifecycle (CRITICAL)
The `service/` package wires dependencies and returns `app.Application`. It MUST NOT create transport servers, bind to network ports, handle OS signals, or manage graceful shutdown. Server lifecycle is a **separate concern** that belongs in a shared server package or the entry point.
`service/` MUST NOT:
- Create transport servers (`grpc.NewServer()`, `http.Server{}`, `message.NewRouter()`)
- Bind to network ports (`net.Listen()`)
- Handle OS signals (`signal.NotifyContext()`, `signal.Notify()`)
- Manage graceful shutdown (`GracefulStop()`, `router.Close()`)
- Import port packages (`ports/grpc`, `ports/amqp`, `ports/http`)
`service/` MUST only:
- Create infrastructure clients and adapters
- Wire command/query handlers with dependencies
- Return `app.Application` (and optionally a cleanup function)
**Check procedure:**
1. Scan all files in `service/` for imports of `net`, `os/signal`, `syscall`, transport packages, or `ports/`
2. Flag any function in `service/` that accepts or creates a server, listener, or router
3. A file named `server.go` in `service/` is a strong signal of violation
**Correct:**
```go
// service/service.go — only wires the application
func NewApplication(ctx context.Context, cfg *config.Config) (app.Application, func()) {
repo := adapters.NewFirestoreRepository(client)
syncer := tokensync.NewSyncer(fetchers, syncRepo, progressTracker)
return newApplication(repo, syncer),
func() { _ = client.Close() }
}
// Server lifecycle lives elsewhere (shared server package or entry point)
```
**Wrong:**
```go
// service/server.go — VIOLATION: server lifecycle in composition root
func RunServer(application app.Application, cfg *config.Config) error {
ctx, stop := signal.NotifyContext(context.Background(), ...) // VIOLATION: signal handling
defer stop()
grpcServer := grpc.NewServer() // VIOLATION: transport server
pb.RegisterCommandsServer(grpcServer, ports.NewServer(app)) // VIOLATION: imports ports/
lis, _ := net.Listen("tcp", fmt.Sprintf(":%s", cfg.Port)) // VIOLATION: network binding
go grpcServer.Serve(lis) // VIOLATION: server lifecycle
<-ctx.Done()
grpcServer.GracefulStop() // VIOLATION: shutdown management
return nil
}
```
---
## ARCH-08: Unified Server with Named Components and OnShutdown (WARNING)
When a project has multiple transports (gRPC, HTTP, AMQP/Watermill), the shared server package SHOULD provide a **single `server.New(...).Run(ctx)`** with functional options per transport and an explicit `OnShutdown` that declares the shutdown sequence.
### Why explicit shutdown ordering matters
Different services have different dependency graphs between transports:
- A consumer that calls gRPC must stop consuming *before* gRPC clients close
- An HTTP API that publishes events must drain HTTP *before* the publisher closes
- Two independent ingress points (HTTP + gRPC) can shut down in parallel
Implicit ordering (LIFO based on registration) is fragile — reordering lines silently changes shutdown behavior. `OnShutdown` makes the sequence a readable, reviewable declaration.
### Core types
```go
// server/server.go
type Server struct {
components map[string]component
startOrder []string
shutdownSteps []ShutdownStep
}
type component struct {
name string
start func(ctx context.Context) error
stop func(ctx context.Context) error
}
type Option func(*Server)
type ShutdownStep struct {
componentNames []string
fn func(ctx context.Context) error
}
```
### API
```go
// Stop creates a step that stops named components.
// Multiple names = parallel shutdown within the step.
func Stop(names ...string) ShutdownStep
// StopFunc creates a step that runs an arbitrary cleanup function.
func StopFunc(fn func()) ShutdownStep
// StopFuncWithErr creates a step with error return.
func StopFuncWithErr(fn func(ctx context.Context) error) ShutdownStep
// OnShutdown declares the shutdown sequence.
// Steps execute top-to-bottom. Each step completes before the next starts.
// Components not mentioned stop last (with a warning log).
func OnShutdown(steps ...ShutdownStep) Option
```
### Shutdown execution
1. Steps execute sequentially in declaration order
2. Within a `Stop("a", "b")` call, components stop in parallel
3. Each step's `wg.Wait()` completes before the next step begins
4. Components not mentioned in any `Stop()` get a catch-all parallel stop after all explicit steps (with a warning log — every component should be in OnShutdown)
5. A global timeout (default 30s) bounds the entire sequence
### Key design principles
- Each `With*` option takes a `name string` as first argument — used in `Stop(name)` to reference it
- `OnShutdown` reads top-to-bottom as a shutdown script
- The factory owns `signal.NotifyContext` — callers never handle signals
- `defer cleanup()` from `NewApplication` naturally runs after `Run()` returns — it is the implicit last phase
- Duplicate component names panic at startup — caught immediately
**Check procedure:**
1. If a project uses 2+ transports, verify `server.New()` is used (not multiple `Run*Server` calls)
2. Verify `OnShutdown` is present and lists all components
3. Verify shutdown order makes sense: consumers before servers, servers before clients
4. No `signal.NotifyContext`, `net.Listen`, or `GracefulStop` calls outside `common/server/`
**Correct:**
```go
// Trainer: HTTP + gRPC + Watermill consumer
func main() {
logs.Init()
ctx := context.Background()
app, cleanup := service.NewApplication(ctx)
defer cleanup()
server.New(
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
ports.RegisterEventHandlers(r, sub, app)
}),
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
}),
server.WithGRPCServer("grpc", func(s *grpc.Server) {
trainer.RegisterTrainerServiceServer(s, ports.NewGrpcServer(app))
}),
server.OnShutdown(
server.Stop("events"), // 1. stop consuming
server.Stop("api", "grpc"), // 2. drain both servers in parallel
server.StopFunc(cleanup), // 3. close clients & publisher
),
).Run(ctx)
}
// Trainings: HTTP-only, publishes events (publisher in cleanup)
func main() {
logs.Init()
ctx := context.Background()
app, cleanup := service.NewApplication(ctx)
defer cleanup()
server.New(
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
}),
server.OnShutdown(
server.Stop("api"), // 1. drain HTTP (in-flight may publish events)
server.StopFunc(cleanup), // 2. close publisher + gRPC clients
),
).Run(ctx)
}
```
**Wrong:**
```go
// VIOLATION: implicit LIFO ordering — fragile
server.New(
server.WithHTTPHandler("api", createHandler),
server.WithWatermillRouter("events", configureRouter),
// no OnShutdown — relies on registration order
).Run(ctx)
// VIOLATION: manual lifecycle per transport
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
grpcServer := grpc.NewServer()
go grpcServer.Serve(lis)
router, _ := message.NewRouter(...)
go router.Run(ctx)
<-ctx.Done()
grpcServer.GracefulStop()
router.Close()
}
```
@@ -0,0 +1,220 @@
# Code Style Rules (STYLE-01..08)
## STYLE-01: Import Grouping (INFO)
Imports MUST be organized in groups separated by blank lines:
1. Standard library
2. External packages (third-party + internal modules)
```go
import (
"context"
"fmt"
"time"
"github.com/sirupsen/logrus"
"github.com/example/myproject/internal/trainer/domain/hour"
)
```
**Wrong:**
```go
import (
"context"
"github.com/sirupsen/logrus" // VIOLATION: mixed with stdlib
"fmt"
"time"
)
```
---
## STYLE-02: Receiver Conventions (INFO)
- **Pointer receivers** (`*Type`) for methods that mutate state
- **Value receivers** (`Type`) for methods that only read state
```go
// Mutates — pointer receiver
func (h *Hour) ScheduleTraining() error {
h.availability = TrainingScheduled
return nil
}
// Read-only — value receiver
func (h Hour) IsAvailable() bool {
return h.availability == Available
}
func (a Availability) IsZero() bool {
return a == Availability{}
}
```
Receiver names should be short (1-2 chars), typically the first letter of the type.
---
## STYLE-03: t.Parallel() in Tests (WARNING)
Every test function and subtest SHOULD call `t.Parallel()` as its first statement.
```go
func TestScheduleTraining(t *testing.T) {
t.Parallel()
// ... test code
}
func TestRepository(t *testing.T) {
t.Parallel()
for i := range testCases {
tc := testCases[i]
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
// ... test code
})
}
}
```
---
## STYLE-04: require vs assert (INFO)
Use the testify library with:
- **`require`** for setup/preconditions that must succeed (fatal on failure)
- **`assert`** for actual test assertions (non-fatal, continues test)
```go
func TestSomething(t *testing.T) {
// Setup — use require (fatal if fails)
hour, err := hour.NewAvailableHour(testTime)
require.NoError(t, err)
// Act
err = hour.ScheduleTraining()
// Assert — use assert (non-fatal)
assert.NoError(t, err)
assert.Equal(t, hour.TrainingScheduled, hour.Availability())
}
```
---
## STYLE-05: Loop Variable Capture (WARNING)
When using loop variables in goroutines or subtests, ALWAYS capture them first.
```go
for i := range repositories {
r := repositories[i] // capture before subtest
t.Run(r.Name, func(t *testing.T) {
t.Parallel()
testUpdateHour(t, r.Repository)
})
}
```
**Note:** Go 1.22+ fixes loop variable capture for `range` loops, but the explicit capture pattern is still preferred for clarity and backward compatibility.
---
## STYLE-06: Table-Driven Tests (INFO)
Tests with multiple cases SHOULD use table-driven pattern with named test cases.
```go
func TestValidateTime(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Hour time.Time
ExpectedErr error
}{
{
Name: "valid_hour",
Hour: time.Now().Truncate(time.Hour).Add(24 * time.Hour),
ExpectedErr: nil,
},
{
Name: "past_hour",
Hour: time.Now().Add(-time.Hour),
ExpectedErr: ErrPastHour,
},
{
Name: "not_full_hour",
Hour: time.Now().Add(30 * time.Minute),
ExpectedErr: ErrNotFullHour,
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
err := validateTime(tc.Hour)
assert.ErrorIs(t, err, tc.ExpectedErr)
})
}
}
```
---
## STYLE-07: Interfaces Where Consumed (WARNING)
Interfaces MUST be defined in the package that **uses** them, not the package that implements them. This follows Go's implicit interface philosophy.
**Correct:**
```go
// domain/hour/repository.go — consumer defines what it needs
package hour
type Repository interface {
GetHour(ctx context.Context, hourTime time.Time) (*Hour, error)
UpdateHour(ctx context.Context, hourTime time.Time,
updateFn func(h *Hour) (*Hour, error)) error
}
// adapters/ — implicitly implements it
package adapters
type FirestoreHourRepository struct { ... }
func (r *FirestoreHourRepository) GetHour(...) (*hour.Hour, error) { ... }
func (r *FirestoreHourRepository) UpdateHour(...) error { ... }
```
**Wrong:**
```go
// adapters/interfaces.go ← VIOLATION
package adapters
type HourRepository interface { ... } // interface where implemented, not consumed
```
---
## STYLE-08: Context as First Parameter (WARNING)
All methods that perform I/O (database, HTTP, gRPC, file) MUST accept `context.Context` as their first parameter.
```go
// Repository methods
GetHour(ctx context.Context, hourTime time.Time) (*Hour, error)
UpdateHour(ctx context.Context, hourTime time.Time, updateFn func(h *Hour) (*Hour, error)) error
// Handler methods
Handle(ctx context.Context, cmd CancelTraining) error
Handle(ctx context.Context, q AvailableHours) ([]Date, error)
// Adapter methods
func (r *FirestoreHourRepository) GetHour(ctx context.Context, hourTime time.Time) (*hour.Hour, error)
```
**Wrong:**
```go
func (r *Repo) GetHour(hourTime time.Time) (*Hour, error) // VIOLATION: no context
func (r *Repo) GetHour(hourTime time.Time, ctx context.Context) (*Hour, error) // VIOLATION: ctx not first
```
@@ -0,0 +1,276 @@
# CQRS Rules (CQRS-01..10)
## CQRS-01: Command Struct Pattern (CRITICAL)
Commands MUST be:
- Named with imperative verb + noun (domain language, NOT CRUD)
- Plain data structs (no methods, no interfaces)
- Their handler returns `error` only — no data
**Correct:**
```go
type ScheduleTraining struct {
Hour time.Time
}
type CancelTraining struct {
Hour time.Time
}
type MakeHoursAvailable struct {
Hours []time.Time
}
```
**Wrong:**
```go
type CreateTraining struct { ... } // VIOLATION: CRUD naming
type UpdateHour struct { ... } // VIOLATION: CRUD naming
```
---
## CQRS-02: Query Struct Pattern (CRITICAL)
Queries MUST be:
- Named with noun phrases (NOT "Get" + noun)
- Plain data structs
- Their handler returns `(ResultType, error)`
**Correct:**
```go
type AvailableHours struct {
From time.Time
To time.Time
}
type HourAvailability struct {
Hour time.Time
}
```
**Wrong:**
```go
type GetAvailableHours struct { ... } // VIOLATION: "Get" prefix
type FetchTrainings struct { ... } // VIOLATION: "Fetch" prefix
```
---
## CQRS-03: Exported Handler Type Alias (WARNING)
Each handler file MUST define an exported type alias using the generic decorator interface.
```go
// For commands:
type CancelTrainingHandler decorator.CommandHandler[CancelTraining]
// For queries:
type AvailableHoursHandler decorator.QueryHandler[AvailableHours, []Date]
```
This allows callers to depend on the decorated interface, not the concrete struct.
---
## CQRS-04: Unexported Handler Struct (WARNING)
The concrete handler struct MUST be unexported (lowercase). It holds dependencies injected via constructor.
```go
type cancelTrainingHandler struct {
hourRepo hour.Repository
}
type availableHoursHandler struct {
readModel AvailableHoursReadModel
}
```
---
## CQRS-05: Constructor Wraps with Decorators (WARNING)
Handler constructors MUST wrap the concrete handler with `ApplyCommandDecorators` or `ApplyQueryDecorators`.
```go
func NewCancelTrainingHandler(
hourRepo hour.Repository,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) CancelTrainingHandler {
return decorator.ApplyCommandDecorators[CancelTraining](
cancelTrainingHandler{hourRepo: hourRepo},
logger,
metricsClient,
)
}
func NewAvailableHoursHandler(
readModel AvailableHoursReadModel,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) AvailableHoursHandler {
return decorator.ApplyQueryDecorators[AvailableHours, []Date](
availableHoursHandler{readModel: readModel},
logger,
metricsClient,
)
}
```
---
## CQRS-06: Constructor Nil-Checks with Panic (WARNING)
Handler constructors SHOULD nil-check all injected dependencies and panic if any are nil. This is a fail-fast pattern — misconfiguration is caught at startup, not at runtime.
```go
func NewCancelTrainingHandler(
hourRepo hour.Repository,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) CancelTrainingHandler {
if hourRepo == nil {
panic("nil hourRepo")
}
if logger == nil {
panic("nil logger")
}
if metricsClient == nil {
panic("nil metricsClient")
}
return decorator.ApplyCommandDecorators[CancelTraining](
cancelTrainingHandler{hourRepo: hourRepo},
logger,
metricsClient,
)
}
```
---
## CQRS-07: Application Struct (CRITICAL)
The `app/app.go` file MUST define an `Application` struct that bundles `Commands` and `Queries` sub-structs.
```go
type Application struct {
Commands Commands
Queries Queries
}
type Commands struct {
CancelTraining command.CancelTrainingHandler
ScheduleTraining command.ScheduleTrainingHandler
MakeHoursAvailable command.MakeHoursAvailableHandler
MakeHoursUnavailable command.MakeHoursUnavailableHandler
}
type Queries struct {
HourAvailability query.HourAvailabilityHandler
TrainerAvailableHours query.AvailableHoursHandler
}
```
**Check:** Look for `app.go` in the `app/` package. Verify it has `Application`, `Commands`, and `Queries` types.
---
## CQRS-08: Read Model Interface for Queries (WARNING)
Query handlers SHOULD depend on a dedicated read model interface, not the write repository.
```go
// In app/query/ — defines what it needs
type AvailableHoursReadModel interface {
AvailableHours(ctx context.Context, from, to time.Time) ([]Date, error)
}
```
This keeps reads and writes separate. The same adapter may implement both the write `Repository` and a read model interface, but the query handler only knows about the read model.
---
## CQRS-09: Command/Query Separation (CRITICAL)
- **Commands** MUST modify state and return only `error`
- **Queries** MUST read state and return `(ResultType, error)` — they MUST NOT modify state
A handler that both reads and writes violates CQRS.
**Check:** Command handlers returning anything besides `error` is a violation. Query handlers calling mutation methods on repositories is a violation.
---
## CQRS-10: No Business Logic in Handlers (WARNING)
Handlers are orchestrators. Business rules live in domain entities.
**Correct** — handler delegates to domain:
```go
func (h cancelTrainingHandler) Handle(ctx context.Context, cmd CancelTraining) error {
return h.hourRepo.UpdateHour(ctx, cmd.Hour, func(h *hour.Hour) (*hour.Hour, error) {
if err := h.CancelTraining(); err != nil { // domain method
return nil, err
}
return h, nil
})
}
```
**Wrong** — business logic in handler:
```go
func (h cancelTrainingHandler) Handle(ctx context.Context, cmd CancelTraining) error {
hour, _ := h.hourRepo.GetHour(ctx, cmd.Hour)
if hour.Availability != "training_scheduled" { // VIOLATION: logic belongs in domain
return errors.New("no training to cancel")
}
hour.Availability = "available" // VIOLATION: direct field mutation
return h.hourRepo.Save(ctx, hour)
}
```
---
## Complete Handler File Template
Every command/query handler file follows this 4-component pattern:
```go
package command
// 1. Command struct
type CancelTraining struct {
Hour time.Time
}
// 2. Exported handler type (alias to decorator interface)
type CancelTrainingHandler decorator.CommandHandler[CancelTraining]
// 3. Unexported concrete handler
type cancelTrainingHandler struct {
hourRepo hour.Repository
}
// 4. Constructor with nil-checks + decorator wrapping
func NewCancelTrainingHandler(
hourRepo hour.Repository,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) CancelTrainingHandler {
if hourRepo == nil {
panic("nil hourRepo")
}
return decorator.ApplyCommandDecorators[CancelTraining](
cancelTrainingHandler{hourRepo: hourRepo},
logger,
metricsClient,
)
}
// Handle method on unexported struct
func (h cancelTrainingHandler) Handle(ctx context.Context, cmd CancelTraining) error {
// orchestration only — delegate to domain
}
```
@@ -0,0 +1,265 @@
# Domain Rules (DOM-01..09)
## DOM-01: Private Entity Fields (CRITICAL)
ALL entity struct fields MUST be unexported (lowercase). Entities are "types with behavior," not data bags.
**Check:** Scan all structs in `domain/` for exported fields. Any uppercase field name is a violation.
**Correct:**
```go
type Hour struct {
hour time.Time
availability Availability
}
```
**Wrong:**
```go
type Hour struct {
Hour time.Time // VIOLATION: exported field
Availability Availability // VIOLATION: exported field
}
```
**Exception:** DB model structs in `adapters/` MAY have exported fields for serialization tags.
---
## DOM-02: Factory Constructors (WARNING)
Entities MUST be created through factory constructors, never by direct struct literal.
Pattern: `func New{Type}(args...) (*Type, error)`
The constructor:
- Validates all invariants
- Returns an error if validation fails
- Returns a pointer to the new entity
**Reference:**
```go
func NewAvailableHour(hour time.Time) (*Hour, error) {
if err := validateTime(hour); err != nil {
return nil, err
}
return &Hour{hour: hour, availability: Available}, nil
}
func NewTraining(uuid, userUUID, userName string, trainingTime time.Time) (*Training, error) {
if uuid == "" {
return nil, errors.New("empty training uuid")
}
if userUUID == "" {
return nil, errors.New("empty training user uuid")
}
// ... validate all fields
return &Training{uuid: uuid, userUUID: userUUID, userName: userName, time: trainingTime}, nil
}
```
---
## DOM-03: MustNew Panic Constructors (INFO)
For use in tests and initialization code, provide `MustNew{Type}` that panics on error.
```go
func MustNewFactory(fc FactoryConfig) Factory {
f, err := NewFactory(fc)
if err != nil {
panic(err)
}
return f
}
```
---
## DOM-04: UnmarshalFromDatabase (WARNING)
Entities MUST provide an `Unmarshal{Type}FromDatabase` function for reconstruction from persistence. This function:
- Bypasses normal validation (data was already valid when stored)
- Accepts all fields needed to reconstruct full state
- Is used ONLY by repository adapters
**Reference:**
```go
func UnmarshalHourFromDatabase(hour time.Time, availability Availability) *Hour {
return &Hour{hour: hour, availability: availability}
}
func UnmarshalTrainingFromDatabase(
uuid, userUUID, userName string,
trainingTime time.Time,
notes string,
canceled bool,
proposedNewTime time.Time,
moveProposedBy UserType,
) (*Training, error) {
return &Training{
uuid: uuid, userUUID: userUUID, userName: userName,
time: trainingTime, notes: notes, canceled: canceled,
proposedNewTime: proposedNewTime, moveProposedBy: moveProposedBy,
}, nil
}
```
---
## DOM-05: Value Objects as Structs (CRITICAL)
Value objects MUST be structs wrapping a private field, NOT raw strings, ints, or type aliases.
This ensures they cannot be constructed with arbitrary values — only through validated constructors or predefined constants.
**Correct:**
```go
type Availability struct {
a string // private — cannot be set directly
}
var (
Available = Availability{"available"}
NotAvailable = Availability{"not_available"}
TrainingScheduled = Availability{"training_scheduled"}
)
type UserType struct {
s string
}
var (
Trainer = UserType{"trainer"}
Attendee = UserType{"attendee"}
)
```
**Wrong:**
```go
type Availability string // VIOLATION: can be set to any string
const (
Available Availability = "available"
NotAvailable Availability = "not_available"
)
```
---
## DOM-06: IsZero Method (WARNING)
Value objects and factory structs SHOULD implement `IsZero() bool` to check for zero-value state.
```go
func (a Availability) IsZero() bool {
return a == Availability{}
}
func (f Factory) IsZero() bool {
return f == Factory{}
}
```
---
## DOM-07: Behavior Methods Use Domain Language (CRITICAL)
Entity methods MUST use domain-specific language, NOT generic CRUD terms.
| Forbidden | Use Instead |
|-----------|------------|
| `SetStatus`, `Update` | `ScheduleTraining`, `CancelTraining`, `MakeAvailable` |
| `Create` | `Schedule`, `Register`, `Place`, `Submit` |
| `Delete` | `Cancel`, `Archive`, `Revoke` |
| `Get` | Use query noun phrases |
**Reference:**
```go
func (h *Hour) ScheduleTraining() error {
if !h.IsAvailable() {
return ErrHourNotAvailable
}
h.availability = TrainingScheduled
return nil
}
func (h *Hour) CancelTraining() error { ... }
func (h *Hour) MakeAvailable() error { ... }
func (h *Hour) MakeNotAvailable() error { ... }
func (t *Training) ProposeReschedule(newTime time.Time, proposedBy UserType) error { ... }
func (t *Training) ApproveReschedule(approvedBy UserType) error { ... }
func (t *Training) RejectReschedule() error { ... }
```
---
## DOM-08: String Constructors Validate Input (WARNING)
When a value object can be constructed from a string, use `New{Type}FromString` with validation.
```go
func NewAvailabilityFromString(availabilityStr string) (Availability, error) {
switch availabilityStr {
case "available":
return Available, nil
case "not_available":
return NotAvailable, nil
case "training_scheduled":
return TrainingScheduled, nil
default:
return Availability{}, fmt.Errorf("unknown availability: %s", availabilityStr)
}
}
```
---
## DOM-09: Factory Struct for Complex Creation (INFO)
When entity creation requires configuration or external dependencies, use a Factory struct pattern.
```go
type FactoryConfig struct {
MaxWeeksInTheFutureToSet int
MinUtcHour int
MaxUtcHour int
}
func (c FactoryConfig) Validate() error {
var errs []error
if c.MaxWeeksInTheFutureToSet <= 0 {
errs = append(errs, errors.New("MaxWeeksInTheFutureToSet must be > 0"))
}
// ... more validations
return multierr.Combine(errs...)
}
type Factory struct {
fc FactoryConfig
}
func NewFactory(fc FactoryConfig) (Factory, error) {
if err := fc.Validate(); err != nil {
return Factory{}, err
}
return Factory{fc: fc}, nil
}
func MustNewFactory(fc FactoryConfig) Factory {
f, err := NewFactory(fc)
if err != nil {
panic(err)
}
return f
}
func (f Factory) IsZero() bool {
return f == Factory{}
}
func (f Factory) NewAvailableHour(hour time.Time) (*Hour, error) {
// uses f.fc for validation bounds
}
```
@@ -0,0 +1,150 @@
# Error Rules (ERR-01..05)
## Three-Tier Error Architecture
The error system has three tiers:
1. **Domain errors** — sentinel variables and typed structs in `domain/`
2. **Application errors**`SlugError` with machine-readable slugs in `app/`
3. **Port errors** — protocol-specific error mapping in `ports/`
---
## ERR-01: Sentinel Error Variables (WARNING)
Simple domain errors without context SHOULD use sentinel `var` declarations.
```go
// domain/hour/errors.go
var (
ErrNotFullHour = errors.New("hour should be a full hour")
ErrPastHour = errors.New("cannot create hour in the past")
ErrTrainingScheduled = errors.New("unable to modify hour, because scheduled training")
ErrHourNotAvailable = errors.New("hour is not available")
ErrNoTrainingScheduled = errors.New("no training scheduled")
)
```
**Naming:** `Err{DescriptiveName}` — always starts with `Err`.
**Usage in domain methods:**
```go
func (h *Hour) ScheduleTraining() error {
if !h.IsAvailable() {
return ErrHourNotAvailable
}
h.availability = TrainingScheduled
return nil
}
```
---
## ERR-02: Typed Error Structs (WARNING)
Errors that carry context (values for logging/display) SHOULD be typed structs implementing the `error` interface.
```go
type TooDistantDateError struct {
MaxWeeksInTheFutureToSet int
ProvidedDate time.Time
}
func (e TooDistantDateError) Error() string {
return fmt.Sprintf(
"schedule can be only set for next %d weeks, provided date: %s",
e.MaxWeeksInTheFutureToSet, e.ProvidedDate,
)
}
type TooEarlyHourError struct {
MinUtcHour int
ProvidedTime time.Time
}
type ForbiddenToSeeTrainingError struct {
RequestingUserUUID string
TrainingOwnerUUID string
}
type NotFoundError struct {
TrainingUUID string
}
```
**Naming:** `{Condition}Error` — describes the error condition.
---
## ERR-03: SlugError for Application Layer (WARNING)
Application-layer errors (command/query handlers) SHOULD use `SlugError` from the common errors package. SlugErrors carry:
- Human-readable error message
- Machine-readable slug (used by API clients)
- Error type (authorization, incorrect-input, unknown)
```go
// common/errors/errors.go
type ErrorType struct {
t string
}
var (
ErrorTypeUnknown = ErrorType{"unknown"}
ErrorTypeAuthorization = ErrorType{"authorization"}
ErrorTypeIncorrectInput = ErrorType{"incorrect-input"}
)
type SlugError struct {
error string
slug string
errorType ErrorType
}
func NewSlugError(error string, slug string) SlugError
func NewAuthorizationError(error string, slug string) SlugError
func NewIncorrectInputError(error string, slug string) SlugError
```
**Usage in handlers:**
```go
func (h cancelTrainingHandler) Handle(ctx context.Context, cmd CancelTraining) error {
if err := h.hourRepo.UpdateHour(ctx, cmd.Hour, func(h *hour.Hour) (*hour.Hour, error) {
if err := h.CancelTraining(); err != nil {
return nil, err
}
return h, nil
}); err != nil {
return errors.NewSlugError(err.Error(), "unable-to-update-availability")
}
return nil
}
```
---
## ERR-04: Error Wrapping with Context (INFO)
When re-raising errors, wrap them with context using `fmt.Errorf("context: %w", err)` or a wrapping library.
```go
// In adapters
if err := doc.DataTo(&model); err != nil {
return nil, fmt.Errorf("unmarshaling hour from firestore: %w", err)
}
```
---
## ERR-05: No Bare fmt.Errorf in Domain (CRITICAL)
The domain package MUST NOT use `fmt.Errorf` for error creation. Domain errors must be either:
- Sentinel variables (`var ErrX = errors.New(...)`)
- Typed error structs
- Standard `errors.New(...)` for simple cases
**Check:** Grep `domain/` for `fmt.Errorf`. Any match in non-test files is a violation.
**Rationale:** `fmt.Errorf` creates untyped errors that cannot be checked with `errors.Is` or `errors.As`. Domain errors should be programmatically handleable.
**Exception:** `fmt.Errorf` with `%w` for wrapping IS acceptable in domain validation helpers that combine multiple checks, but prefer typed errors or sentinel variables.
@@ -0,0 +1,52 @@
# Naming Rules
## Strict Naming Convention Table
| Pattern | Convention | Example |
|---------|-----------|---------|
| Entity constructor | `New{Type}(args...) (*Type, error)` | `NewTraining(...)`, `NewAvailableHour(...)` |
| Panic constructor | `MustNew{Type}(args...) Type` | `MustNewFactory(...)`, `MustNewUser(...)` |
| DB reconstruction | `Unmarshal{Type}FromDatabase(...)` | `UnmarshalHourFromDatabase(...)` |
| Value from string | `New{Type}FromString(s string) (Type, error)` | `NewAvailabilityFromString(...)` |
| Command struct | Imperative verb + noun (PascalCase) | `ScheduleTraining`, `CancelTraining`, `MakeHoursAvailable` |
| Query struct | Noun phrase (PascalCase) | `AvailableHours`, `HourAvailability`, `AllTrainings` |
| Handler type (exported) | `{ActionName}Handler` | `ScheduleTrainingHandler`, `CancelTrainingHandler` |
| Handler struct (unexported) | `{actionName}Handler` | `scheduleTrainingHandler`, `cancelTrainingHandler` |
| Handler constructor | `New{ActionName}Handler(...)` | `NewScheduleTrainingHandler(...)` |
| Adapter type | Technology suffix | `FirestoreHourRepository`, `MySQLHourRepository`, `MemoryHourRepository` |
| Adapter constructor | `New{Tech}{Entity}Repository(...)` | `NewFirestoreHourRepository(...)` |
| DB model (SQL) | Tech prefix, unexported | `mysqlHour`, `postgresTraining` |
| DB model (NoSQL) | `{Entity}Model` (exported for tags) | `TrainingModel`, `DateModel` |
| Sentinel errors | `Err{Name}` | `ErrNotFullHour`, `ErrHourNotAvailable` |
| Typed errors | `{Condition}Error` | `TooDistantDateError`, `NotFoundError` |
| Zero check | `IsZero() bool` | `Availability.IsZero()`, `Factory.IsZero()` |
| Application struct | `Application` in `app/` package | `app.Application` |
| App sub-structs | `Commands`, `Queries` | `app.Commands`, `app.Queries` |
| Composition root | `NewApplication(...)` in `service/` | `service.NewApplication(ctx)` |
| gRPC client adapter | `{Service}Grpc` | `TrainerGrpc`, `UsersGrpc` |
| Read model interface | `{Query}ReadModel` | `AvailableHoursReadModel` |
## CRUD-to-Domain-Language Mapping
CRUD terms are **forbidden** in domain code, commands, queries, and API endpoints. Use domain-specific language instead.
| CRUD Term | Replacement Options | Example |
|-----------|-------------------|---------|
| Create | Schedule, Register, Place, Submit, Open, Enroll | `ScheduleTraining`, not `CreateTraining` |
| Read | *(use noun phrase queries)* | `AvailableHours`, not `GetHours` |
| Update | Approve, Reject, Reschedule, Move, Modify, Assign | `ApproveReschedule`, not `UpdateTraining` |
| Delete | Cancel, Archive, Revoke, Close, Withdraw | `CancelTraining`, not `DeleteTraining` |
| Get | *(avoid as prefix)* | `HourAvailability`, not `GetHourAvailability` |
| Set | *(use specific verb)* | `MakeAvailable`, not `SetAvailability` |
| List | *(use noun phrase)* | `AllTrainings`, not `ListTrainings` |
| Fetch | *(avoid entirely)* | Use noun phrase queries |
## Check Procedure
1. Scan all type declarations and function names
2. Flag any use of Create/Read/Update/Delete/Get/Set/List/Fetch in:
- Command struct names
- Query struct names
- Domain entity method names
- Handler type names
3. Severity: CRITICAL for command/query names, WARNING for methods
@@ -0,0 +1,179 @@
# Port Rules (PORT-01..06)
## PORT-01: Handler Struct Holds Application (WARNING)
HTTP and gRPC handler structs MUST hold `app.Application` and delegate to it. They are thin wrappers.
```go
// ports/http.go
type HttpServer struct {
app app.Application
}
// ports/grpc.go
type GrpcServer struct {
app app.Application
}
```
---
## PORT-02: Error Mapping (WARNING)
Ports MUST map application errors to protocol-specific responses. They must NOT leak internal error details.
**HTTP — using httperr helper:**
```go
func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) {
err = h.app.Commands.MakeHoursAvailable.Handle(r.Context(), command.MakeHoursAvailable{...})
if err != nil {
httperr.RespondWithSlugError(err, w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
```
**The httperr mapper:**
```go
func RespondWithSlugError(err error, w http.ResponseWriter, r *http.Request) {
slugError, ok := err.(errors.SlugError)
if !ok {
InternalError("internal-server-error", err, w, r)
return
}
switch slugError.ErrorType() {
case errors.ErrorTypeAuthorization:
Unauthorised(slugError.Slug(), slugError, w, r) // 401
case errors.ErrorTypeIncorrectInput:
BadRequest(slugError.Slug(), slugError, w, r) // 400
default:
InternalError(slugError.Slug(), slugError, w, r) // 500
}
}
```
**gRPC — using status codes:**
```go
func (g GrpcServer) ScheduleTraining(ctx context.Context, req *trainer.UpdateHourRequest) (*empty.Empty, error) {
if err := g.app.Commands.ScheduleTraining.Handle(ctx, command.ScheduleTraining{...}); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &empty.Empty{}, nil
}
```
---
## PORT-03: Auth Extracted from Context (WARNING)
Authentication/authorization data MUST be extracted from the request context using a shared auth package, NOT parsed directly in the handler.
**Correct:**
```go
func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) {
user, err := auth.UserFromCtx(r.Context())
if err != nil {
httperr.RespondWithSlugError(err, w, r)
return
}
if user.Role != "trainer" {
httperr.Unauthorised("invalid-role", nil, w, r)
return
}
// ... delegate to app
}
```
**Wrong:**
```go
func (h HttpServer) MakeHourAvailable(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization") // VIOLATION: parsing auth in handler
claims, err := jwt.Parse(token, keyFunc) // VIOLATION: JWT logic in port
// ...
}
```
---
## PORT-04: No Business Logic in Ports (CRITICAL)
Port handlers MUST only:
1. Parse/decode the request
2. Extract auth from context
3. Construct command/query struct
4. Call `app.Commands.X.Handle()` or `app.Queries.X.Handle()`
5. Map the result/error to a response
They MUST NOT contain:
- Domain validation logic
- Business rule checks
- Direct database calls
- State manipulation
**Check:** Port files should only import `app/`, `app/command/`, `app/query/`, and infrastructure packages (HTTP, gRPC, auth). They should NOT import `domain/` directly (except for response mapping types).
---
## PORT-05: Response Model Mapping (INFO)
Response transformation SHOULD be in separate mapping functions, not inline in handlers.
```go
// Mapping function
func dateModelsToResponse(models []query.Date) []Date {
var dates []Date
for _, m := range models {
dates = append(dates, Date{
Date: m.Date,
Hours: hourModelsToResponse(m.Hours),
})
}
return dates
}
// Handler uses it cleanly
func (h HttpServer) GetTrainerAvailableHours(w http.ResponseWriter, r *http.Request, params GetTrainerAvailableHoursParams) {
dateModels, err := h.app.Queries.TrainerAvailableHours.Handle(r.Context(), query.AvailableHours{
From: params.DateFrom,
To: params.DateTo,
})
if err != nil {
httperr.RespondWithSlugError(err, w, r)
return
}
dates := dateModelsToResponse(dateModels)
render.Respond(w, r, dates)
}
```
---
## PORT-06: No Unimplemented Embedding in gRPC Servers (CRITICAL)
gRPC server structs MUST NOT embed `Unimplemented*Server` structs. Omitting the embed enforces **compile-time interface compliance** — if a new RPC is added to the proto definition, the code will fail to compile until the method is explicitly implemented.
Embedding `Unimplemented*Server` silently returns "unimplemented" at runtime for missing methods, hiding broken contracts until a request hits the missing endpoint in production.
**Correct:**
```go
type GrpcServer struct {
app app.Application
}
// Compile error if any RPC method from TrainerServiceServer is missing.
```
**Wrong:**
```go
type GrpcServer struct {
trainer.UnimplementedTrainerServiceServer // VIOLATION: hides missing methods at compile time
app app.Application
}
```
**Check:** Scan all structs in `ports/grpc.go` for embedded `Unimplemented*Server` fields. Any match is a CRITICAL violation.
**Proto generation:** When generating gRPC code, use `require_unimplemented_servers=false` to keep the interface strict:
```
protoc --go-grpc_out=require_unimplemented_servers=false:. *.proto
```
@@ -0,0 +1,181 @@
# Repository Rules (REPO-01..07)
## REPO-01: Interface Defined in Domain (CRITICAL)
Repository interfaces MUST be defined in the domain package, next to the entity they persist. This follows the Dependency Inversion Principle — the domain defines what it needs, adapters implement it.
**Correct:**
```go
// domain/hour/repository.go
package hour
type Repository interface {
GetHour(ctx context.Context, hourTime time.Time) (*Hour, error)
UpdateHour(ctx context.Context, hourTime time.Time,
updateFn func(h *Hour) (*Hour, error)) error
}
```
**Wrong:**
```go
// adapters/repository.go ← VIOLATION: interface in adapter layer
package adapters
type HourRepository interface { ... }
```
**Check:** Grep `domain/` for `type.*Repository interface`. Grep `adapters/` for the same — if found in adapters, it's a violation.
---
## REPO-02: Update Callback Pattern (WARNING)
Repository update methods SHOULD use a callback/closure pattern. The repository handles transaction lifecycle; the callback handles domain logic.
```go
// Interface
UpdateHour(ctx context.Context, hourTime time.Time,
updateFn func(h *Hour) (*Hour, error)) error
// Usage in handler
err := h.hourRepo.UpdateHour(ctx, cmd.Hour, func(h *hour.Hour) (*hour.Hour, error) {
if err := h.CancelTraining(); err != nil {
return nil, err
}
return h, nil
})
```
Benefits:
- Transaction scope is clear
- Domain logic is isolated from persistence details
- Enables optimistic locking, retries, etc. transparently
---
## REPO-03: Separate DB Model Structs (WARNING)
Adapter implementations MUST use separate structs for database representation. Domain entities should NOT have serialization tags.
**Correct:**
```go
// adapters/ — DB model
type mysqlHour struct {
ID int `db:"id"`
Hour time.Time `db:"hour"`
Availability string `db:"availability"`
}
// or for Firestore (needs exported fields for tags)
type TrainingModel struct {
UUID string `firestore:"Uuid"`
UserUUID string `firestore:"UserUuid"`
Time time.Time `firestore:"Time"`
}
// Conversion in adapter
func (r *MySQLHourRepository) toHour(m mysqlHour) (*hour.Hour, error) {
availability, err := hour.NewAvailabilityFromString(m.Availability)
if err != nil {
return nil, err
}
return hour.UnmarshalHourFromDatabase(m.Hour, availability), nil
}
```
**Wrong:**
```go
// domain/hour/hour.go
type Hour struct {
Hour time.Time `json:"hour" db:"hour"` // VIOLATION: DB tags on domain entity
Availability string `json:"availability"` // VIOLATION: serialization concern in domain
}
```
---
## REPO-04: Adapter Constructor Naming (INFO)
Repository adapter constructors follow: `New{Technology}{Entity}Repository`
```go
func NewFirestoreHourRepository(client *firestore.Client, factory hour.Factory) *FirestoreHourRepository
func NewMySQLHourRepository(db *sqlx.DB) *MySQLHourRepository
func NewMemoryHourRepository(factory hour.Factory) *MemoryHourRepository
```
---
## REPO-05: Technology Suffix Naming (INFO)
Adapter types use technology as a suffix/prefix to distinguish implementations.
```go
type FirestoreHourRepository struct { ... }
type MySQLHourRepository struct { ... }
type MemoryHourRepository struct { ... }
// For external service clients
type TrainerGrpc struct { ... }
type UsersGrpc struct { ... }
```
---
## REPO-06: Shared Test Suite (WARNING)
Repository tests SHOULD run the same test logic against ALL implementations (memory, MySQL, Firestore, etc.). This ensures behavioral consistency.
**Pattern:**
```go
func createRepositories(t *testing.T) []Repository {
return []Repository{
{Name: "Firebase", Repository: newFirebaseRepository(t)},
{Name: "MySQL", Repository: newMySQLRepository(t)},
{Name: "memory", Repository: adapters.NewMemoryHourRepository(testFactory)},
}
}
func TestRepository(t *testing.T) {
repositories := createRepositories(t)
for i := range repositories {
r := repositories[i] // capture loop variable
t.Run(r.Name, func(t *testing.T) {
t.Parallel()
testUpdateHour(t, r.Repository)
testUpdateHour_parallel(t, r.Repository)
})
}
}
```
**Check:** Look for test files in `adapters/` that test repository implementations. Verify they use a shared test function or table-driven approach.
---
## REPO-07: UnmarshalFromDatabase Usage (WARNING)
Adapter implementations MUST use the entity's `UnmarshalFromDatabase` function to reconstruct domain objects from persistence, not the regular constructor.
**Correct:**
```go
func (r *FirestoreHourRepository) toHour(doc *firestore.DocumentSnapshot) (*hour.Hour, error) {
var m HourModel
if err := doc.DataTo(&m); err != nil {
return nil, err
}
availability, err := hour.NewAvailabilityFromString(m.Availability)
if err != nil {
return nil, err
}
return hour.UnmarshalHourFromDatabase(m.Hour, availability), nil
}
```
**Wrong:**
```go
func (r *FirestoreHourRepository) toHour(doc *firestore.DocumentSnapshot) (*hour.Hour, error) {
// VIOLATION: using business constructor for DB reconstruction
return hour.NewAvailableHour(m.Hour) // This re-validates and may reject valid stored data
}
```
@@ -0,0 +1,407 @@
# Watermill Rules (WM-01..10)
## WM-01: Watermill as a Named Component in Unified Server (CRITICAL)
Watermill router MUST be registered as a named component via `server.WithWatermillRouter(name, configure)` — same pattern as `WithHTTPHandler` and `WithGRPCServer`. The `With*` option owns AMQP connection, middleware, and router lifecycle. The caller provides **only handler registration** via callback.
This ensures:
- Middleware stack (retry, correlation, recovery) is consistent across all services
- Broker config is centralized — swapping AMQP for Kafka changes one file
- Shutdown ordering is explicit via `server.OnShutdown(server.Stop(name))`
**Check procedure:**
1. Scan `main.go` for direct Watermill router creation (`message.NewRouter`, `amqp.NewSubscriber`)
2. Flag any middleware setup outside `server/watermill.go`
3. Verify Watermill component appears in `OnShutdown` with correct ordering
**Correct:**
```go
// internal/common/server/watermill.go
func WithWatermillRouter(
name string,
configure func(*message.Router, message.Subscriber),
) Option {
return func(s *Server) {
wmLogger := watermill.NewStdLoggerWithOut(os.Stdout, true, false)
amqpURI := os.Getenv("AMQP_URI")
amqpConfig := amqp.NewDurableQueueConfig(amqpURI)
sub, err := amqp.NewSubscriber(amqpConfig, wmLogger)
if err != nil { panic(err) }
r, err := message.NewRouter(message.RouterConfig{}, wmLogger)
if err != nil { panic(err) }
r.AddMiddleware(
wmMiddleware.CorrelationID,
wmMiddleware.Recoverer,
wmMiddleware.Retry{MaxRetries: 3}.Middleware,
)
configure(r, sub)
s.addComponent(name, component{
name: name,
start: func(ctx context.Context) error {
return r.Run(ctx)
},
stop: func(ctx context.Context) error {
return r.Close()
},
})
}
}
// main.go — registered as named component
server.New(
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
ports.RegisterEventHandlers(r, sub, application)
}),
server.WithHTTPHandler("api", createHandler),
server.OnShutdown(
server.Stop("events"), // 1. stop consuming
server.Stop("api"), // 2. drain HTTP
server.StopFunc(cleanup), // 3. close clients
),
).Run(ctx)
```
**Wrong:**
```go
// main.go — VIOLATION: infrastructure in main
func main() {
sub, _ := amqp.NewSubscriber(amqpConfig, logger) // VIOLATION
r, _ := message.NewRouter(message.RouterConfig{}, logger) // VIOLATION
r.AddMiddleware(wmMiddleware.Recoverer) // VIOLATION
r.Run(context.Background())
}
// main.go — VIOLATION: standalone RunWatermillRouter without unified server
server.RunWatermillRouter(func(r *message.Router, sub message.Subscriber) { ... })
// Cannot coordinate shutdown with other transports
```
---
## WM-02: Publisher Factory Returns (Publisher, Close, Error) Triple (CRITICAL)
Publisher creation MUST follow the same `(client, closeFunc, error)` triple-return pattern as `client.NewTrainerClient()` and `client.NewUsersClient()`. Config comes from environment variables.
**Check procedure:**
1. Verify publisher factory in `internal/common/client/watermill.go`
2. Must return `(message.Publisher, func() error, error)`
3. Must read `AMQP_URI` from env
4. Error case must return a no-op close function, never nil
**Correct:**
```go
// internal/common/client/watermill.go
func NewWatermillPublisher() (pub message.Publisher, close func() error, err error) {
amqpURI := os.Getenv("AMQP_URI")
if amqpURI == "" {
return nil, func() error { return nil }, errors.New("empty env AMQP_URI")
}
logger := watermill.NewStdLoggerWithOut(os.Stdout, true, false)
config := amqp.NewDurableQueueConfig(amqpURI)
publisher, err := amqp.NewPublisher(config, logger)
if err != nil {
return nil, func() error { return nil }, errors.Wrap(err, "cannot create watermill publisher")
}
return publisher, publisher.Close, nil
}
```
**Wrong:**
```go
// VIOLATION: returns raw connection, no close function
func NewPublisher() *amqp.Publisher {
pub, _ := amqp.NewPublisher(config, logger)
return pub
}
// VIOLATION: nil close function on error path
func NewPublisher() (message.Publisher, func() error, error) {
// ...
return nil, nil, err // nil close panics on defer
}
```
---
## WM-03: Event Handlers Live in Ports (CRITICAL)
Watermill event handlers are **inbound adapters** — they are ports, just like HTTP and gRPC handlers. They MUST:
- Live in `ports/`
- Hold `app.Application`
- Delegate to command/query handlers
- Contain NO business logic
**Check procedure:**
1. Scan for `message.HandlerFunc` or `func(*message.Message) error` signatures
2. These MUST be in `ports/` package
3. Must import `app/`, `app/command/`, or `app/query/` — not `domain/` directly
4. Must follow the same delegation pattern as HTTP/gRPC handlers
**Correct:**
```go
// ports/event.go
type EventHandlers struct {
app app.Application
}
func RegisterEventHandlers(r *message.Router, sub message.Subscriber, application app.Application) {
handlers := EventHandlers{app: application}
r.AddNoPublisherHandler(
"OnTrainingScheduled",
"training.scheduled",
sub,
handlers.OnTrainingScheduled,
)
}
func (h EventHandlers) OnTrainingScheduled(msg *message.Message) error {
var event TrainingScheduledEvent
if err := json.Unmarshal(msg.Payload, &event); err != nil {
return err
}
return h.app.Commands.ScheduleTraining.Handle(
msg.Context(),
command.ScheduleTraining{Hour: event.Hour},
)
}
```
**Wrong:**
```go
// adapters/event_handler.go — VIOLATION: handler in adapters/
func HandleTrainingScheduled(msg *message.Message) error {
repo.Save(ctx, training) // VIOLATION: direct repo access
}
// app/command/schedule_training.go — VIOLATION: message parsing in app layer
func (h handler) Handle(ctx context.Context, msg *message.Message) error { ... }
```
---
## WM-04: Event Publisher Adapter Implements Domain Interface (WARNING)
Publishing events MUST go through an adapter that implements an interface defined in the app or domain layer. The app layer defines *what* events to publish; the adapter knows *how*.
This keeps Watermill as a swappable infrastructure detail.
**Check procedure:**
1. Look for `message.Publisher` usage — it MUST NOT appear in `app/` or `domain/`
2. An interface like `EventPublisher` should be in `app/command/services.go` or similar
3. The concrete adapter in `adapters/` implements it using Watermill
**Correct:**
```go
// app/command/services.go
type TrainingEventPublisher interface {
TrainingScheduled(ctx context.Context, t training.Training) error
TrainingCancelled(ctx context.Context, trainingUUID string) error
}
// adapters/training_event_publisher.go
type WatermillTrainingEventPublisher struct {
pub message.Publisher
}
func NewWatermillTrainingEventPublisher(pub message.Publisher) WatermillTrainingEventPublisher {
return WatermillTrainingEventPublisher{pub: pub}
}
func (p WatermillTrainingEventPublisher) TrainingScheduled(ctx context.Context, t training.Training) error {
payload, err := json.Marshal(TrainingScheduledEvent{UUID: t.UUID(), Hour: t.Time()})
if err != nil { return err }
msg := message.NewMessage(watermill.NewUUID(), payload)
middleware.SetCorrelationID(middleware.MessageCorrelationID(msg), msg)
return p.pub.Publish("training.scheduled", msg)
}
```
**Wrong:**
```go
// app/command/schedule_training.go — VIOLATION: Watermill in app layer
import "github.com/ThreeDotsLabs/watermill/message"
func (h handler) Handle(ctx context.Context, cmd ScheduleTraining) error {
msg := message.NewMessage(watermill.NewUUID(), payload) // VIOLATION
h.publisher.Publish("topic", msg) // VIOLATION: infra detail
}
```
---
## WM-05: Topic Naming Uses Domain Language (WARNING)
Topic/queue names MUST use domain language with dot notation: `{aggregate}.{past-tense-event}`. No CRUD names, no technical prefixes.
**Correct:**
```
training.scheduled
training.cancelled
training.reschedule_requested
hour.made_available
```
**Wrong:**
```
create-training // VIOLATION: CRUD name
events.training.created // VIOLATION: redundant "events" prefix, CRUD
TRAINING_QUEUE // VIOLATION: technical name, not domain event
```
---
## WM-06: Event Structs Live in the Publishing Port or Adapter (INFO)
Event DTOs (the JSON payloads) are protocol-specific — they belong in `ports/` or `adapters/`, NOT in `domain/`. Domain entities are the canonical model; events are a serialization concern.
**Check procedure:**
1. Look for event structs (e.g., `TrainingScheduledEvent`)
2. They MUST be in `ports/` (if consumed by event handlers) or `adapters/` (if produced by publisher adapters)
3. They MUST NOT be in `domain/`
**Correct:**
```go
// ports/event.go or adapters/training_event_publisher.go
type TrainingScheduledEvent struct {
UUID string `json:"uuid"`
Hour time.Time `json:"hour"`
}
```
---
## WM-07: Watermill Middleware in With* Option Only (WARNING)
Watermill middleware (retry, correlation ID, recoverer, throttle, etc.) MUST be configured exclusively inside the `WithWatermillRouter` option in `internal/common/server/watermill.go` — same principle as ARCH-06 for HTTP/gRPC middleware.
**Check procedure:**
1. Scan for `r.AddMiddleware` or `router.AddMiddleware` calls
2. All MUST be in `internal/common/server/watermill.go` (inside `WithWatermillRouter`)
3. Flag any middleware setup in `main.go`, `ports/`, or `service/`
---
## WM-08: Publisher Cleanup via OnShutdown or Composition Root (WARNING)
When a service publishes events, the publisher's close function MUST be closed as part of the shutdown sequence. Two valid patterns:
**Pattern A — cleanup in OnShutdown (preferred when using unified server):**
```go
server.New(
server.WithHTTPHandler("api", createHandler),
server.OnShutdown(
server.Stop("api"), // 1. drain HTTP (in-flight may publish)
server.StopFunc(cleanup), // 2. close publisher + clients
),
).Run(ctx)
```
**Pattern B — cleanup via defer (simpler services):**
```go
app, cleanup := service.NewApplication(ctx)
defer cleanup() // runs after Run() returns
server.New(
server.WithHTTPHandler("api", createHandler),
server.OnShutdown(
server.Stop("api"),
),
).Run(ctx)
// cleanup() runs here via defer — publisher closes after server drained
```
**Check procedure:**
1. If `service/application.go` creates a publisher, verify close is either in `OnShutdown` or in the cleanup function
2. Publisher close MUST happen *after* all transports that might publish are stopped
3. Closing publisher before draining HTTP/gRPC = lost messages
**Wrong:**
```go
// main.go — VIOLATION: publisher lifecycle in main, not ordered
func main() {
pub, closePub, _ := client.NewWatermillPublisher()
defer closePub() // VIOLATION: may close before HTTP drains
app := service.NewApplication(ctx, pub) // VIOLATION: infra detail leaked
}
```
---
## WM-09: Named Components Replace SERVER_TO_RUN Switch (INFO)
With the unified server pattern (ARCH-08), the `SERVER_TO_RUN` environment variable switch is replaced by composing `With*` options. A service that needs HTTP + Watermill simply registers both.
**Correct — unified server:**
```go
// All transports in one process, explicit shutdown order
server.New(
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
ports.RegisterEventHandlers(r, sub, app)
}),
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
}),
server.OnShutdown(
server.Stop("events"),
server.Stop("api"),
server.StopFunc(cleanup),
),
).Run(ctx)
```
**Also acceptable — SERVER_TO_RUN for single-transport deployments:**
```go
// When deploying each transport as a separate container
switch serverType {
case "http":
server.New(
server.WithHTTPHandler("api", createHandler),
server.OnShutdown(server.Stop("api")),
).Run(ctx)
case "watermill":
server.New(
server.WithWatermillRouter("events", configureRouter),
server.OnShutdown(server.Stop("events")),
).Run(ctx)
}
```
---
## WM-10: No Synchronous Side Effects Replaced by Fire-and-Forget (CRITICAL)
When replacing synchronous gRPC calls with async events, you MUST ensure the operation tolerates eventual consistency. If the caller needs confirmation that the action succeeded, keep it synchronous (gRPC) or use a saga/process manager — do NOT simply drop the response.
**Check procedure:**
1. For each gRPC adapter being replaced by events, check if the calling command inspects the return value or error
2. If the command makes decisions based on the result, it MUST remain synchronous or use a compensation pattern
3. Fire-and-forget is only valid for notifications, projections, and truly independent side effects
**Correct use of async:**
```go
// Notification — caller doesn't need the result
func (h handler) Handle(ctx context.Context, cmd ScheduleTraining) error {
// ... create training ...
// Fire event — consumer will send email, update dashboard, etc.
return h.eventPublisher.TrainingScheduled(ctx, training)
}
```
**Wrong use of async:**
```go
// VIOLATION: caller needs confirmation that hours were reserved
func (h handler) Handle(ctx context.Context, cmd ScheduleTraining) error {
training, _ := training.NewTraining(...)
h.eventPublisher.TrainingScheduled(ctx, training) // VIOLATION: no guarantee hours are available
return h.repo.Save(ctx, training) // saved training without confirmed availability
}
// Previously this was a synchronous gRPC call that could fail and roll back
```
@@ -0,0 +1,115 @@
# Command Handler Scaffold Template
Generate a single command handler file following the 4-component pattern.
## Placeholders
- `{{Name}}` — PascalCase command name (e.g., `ScheduleTraining`)
- `{{name}}` — camelCase (e.g., `scheduleTraining`)
- `{{module}}` — Go module path from go.mod
- `{{entity}}` — Domain entity name, lowercase (e.g., `hour`)
- `{{Entity}}` — Domain entity name, PascalCase (e.g., `Hour`)
## File: `app/command/{{name_snake}}.go`
```go
package command
import (
"context"
"github.com/sirupsen/logrus"
"{{module}}/domain/{{entity}}"
"{{module_common}}/decorator"
)
// 1. Command struct — imperative verb + noun, plain data
type {{Name}} struct {
// TODO: Add command fields
// Example:
// UUID string
// Hour time.Time
}
// 2. Exported handler type alias
type {{Name}}Handler decorator.CommandHandler[{{Name}}]
// 3. Unexported concrete handler struct
type {{name}}Handler struct {
{{entity}}Repo {{entity}}.Repository
}
// 4. Constructor with nil-checks + decorator wrapping
func New{{Name}}Handler(
{{entity}}Repo {{entity}}.Repository,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) {{Name}}Handler {
if {{entity}}Repo == nil {
panic("nil {{entity}}Repo")
}
if logger == nil {
panic("nil logger")
}
if metricsClient == nil {
panic("nil metricsClient")
}
return decorator.ApplyCommandDecorators[{{Name}}](
{{name}}Handler{{"{"}}{{entity}}Repo: {{entity}}Repo},
logger,
metricsClient,
)
}
// Handle — orchestrates domain logic, does NOT contain business rules
func (h {{name}}Handler) Handle(ctx context.Context, cmd {{Name}}) error {
// TODO: Implement command handling
//
// Typical patterns:
//
// Pattern A — Update via callback:
// return h.{{entity}}Repo.Update{{Entity}}(ctx, cmd.UUID, func(e *{{entity}}.{{Entity}}) (*{{entity}}.{{Entity}}, error) {
// if err := e.SomeDomainAction(); err != nil {
// return nil, err
// }
// return e, nil
// })
//
// Pattern B — Create new entity:
// entity, err := {{entity}}.New{{Entity}}(cmd.UUID, ...)
// if err != nil {
// return err
// }
// return h.{{entity}}Repo.Save(ctx, entity)
return nil
}
```
## Update `app/app.go`
After creating the handler, add it to the `Commands` struct:
```go
type Commands struct {
// ... existing handlers ...
{{Name}} command.{{Name}}Handler
}
```
## Update `service/application.go`
Wire the handler in the composition root:
```go
Commands: app.Commands{
// ... existing handlers ...
{{Name}}: command.New{{Name}}Handler(
{{entity}}Repository,
logger,
metricsClient,
),
},
```
@@ -0,0 +1,156 @@
# Domain Entity Scaffold Template
Generate a domain entity with factory constructor, value objects, and errors.
## Placeholders
- `{{Name}}` — PascalCase entity name (e.g., `Training`, `Hour`, `Order`)
- `{{name}}` — camelCase (e.g., `training`)
- `{{name_lower}}` — all lowercase package name (e.g., `training`)
- `{{name_snake}}` — snake_case (e.g., `training`)
## File: `domain/{{name_lower}}/{{name_snake}}.go`
```go
package {{name_lower}}
import (
"errors"
"time"
)
// {{Name}} is the aggregate root for the {{name_lower}} domain.
type {{Name}} struct {
uuid string
createdAt time.Time
// TODO: Add domain fields (all private)
// status Status // value object, not raw string
}
// New{{Name}} creates a new {{Name}} with validated invariants.
func New{{Name}}(uuid string) (*{{Name}}, error) {
if uuid == "" {
return nil, errors.New("empty {{name_lower}} uuid")
}
return &{{Name}}{
uuid: uuid,
createdAt: time.Now(),
}, nil
}
// Unmarshal{{Name}}FromDatabase reconstructs a {{Name}} from persistence.
// Bypasses validation — data was valid when stored.
func Unmarshal{{Name}}FromDatabase(
uuid string,
createdAt time.Time,
// TODO: Add all persisted fields
) *{{Name}} {
return &{{Name}}{
uuid: uuid,
createdAt: createdAt,
}
}
// Accessor methods — expose state without allowing mutation.
func (t {{Name}}) UUID() string {
return t.uuid
}
func (t {{Name}}) CreatedAt() time.Time {
return t.createdAt
}
// TODO: Add behavior methods using domain language.
// Examples:
//
// func (t *{{Name}}) Approve() error {
// if t.status != Pending {
// return ErrNotPending
// }
// t.status = Approved
// return nil
// }
//
// func (t *{{Name}}) Cancel() error { ... }
// func (t *{{Name}}) Submit(details string) error { ... }
```
## File: `domain/{{name_lower}}/errors.go`
```go
package {{name_lower}}
import "errors"
// Sentinel errors — simple, no context needed.
var (
ErrNotFound = errors.New("{{name_lower}} not found")
// TODO: Add domain-specific errors
// ErrAlreadyCanceled = errors.New("{{name_lower}} already canceled")
// ErrNotPending = errors.New("{{name_lower}} is not in pending state")
)
// Typed errors — carry context for logging/display.
// Example:
//
// type ForbiddenError struct {
// RequestingUserUUID string
// OwnerUUID string
// }
//
// func (e ForbiddenError) Error() string {
// return fmt.Sprintf("user %s cannot access {{name_lower}} owned by %s",
// e.RequestingUserUUID, e.OwnerUUID)
// }
```
## File: `domain/{{name_lower}}/status.go` (Optional Value Object)
```go
package {{name_lower}}
import "fmt"
// Status is a value object — cannot be constructed with arbitrary values.
type Status struct {
s string
}
var (
Pending = Status{"pending"}
Approved = Status{"approved"}
Canceled = Status{"canceled"}
)
func NewStatusFromString(s string) (Status, error) {
switch s {
case "pending":
return Pending, nil
case "approved":
return Approved, nil
case "canceled":
return Canceled, nil
default:
return Status{}, fmt.Errorf("unknown {{name_lower}} status: %s", s)
}
}
func (s Status) String() string {
return s.s
}
func (s Status) IsZero() bool {
return s == Status{}
}
```
## Post-Creation Checklist
- [ ] All struct fields are private (unexported)
- [ ] Factory constructor validates all invariants
- [ ] UnmarshalFromDatabase accepts all persisted fields
- [ ] Value objects are struct wrappers, not type aliases
- [ ] Behavior methods use domain language, not CRUD
- [ ] Errors are sentinel vars or typed structs
@@ -0,0 +1,99 @@
# Event Handler Scaffold Template
Generate a Watermill event handler port and its registration function. Event handlers are inbound adapters — they live in `ports/` and delegate to CQRS command/query handlers, identical to HTTP and gRPC handlers.
## Placeholders
- `{{Name}}` — PascalCase event name (e.g., `TrainingScheduled`)
- `{{name}}` — camelCase (e.g., `trainingScheduled`)
- `{{name_snake}}` — snake_case (e.g., `training_scheduled`)
- `{{topic}}` — Dot-notation topic name (e.g., `training.scheduled`)
- `{{module}}` — Go module path from go.mod
- `{{command}}` — Command to invoke, PascalCase (e.g., `ScheduleTraining`)
## File: `ports/event.go`
If this file already exists, append the handler method and registration line. If not, create it:
```go
package ports
import (
"encoding/json"
"github.com/ThreeDotsLabs/watermill/message"
"{{module}}/app"
"{{module}}/app/command"
)
type EventHandlers struct {
app app.Application
}
func RegisterEventHandlers(r *message.Router, sub message.Subscriber, application app.Application) {
handlers := EventHandlers{app: application}
r.AddNoPublisherHandler(
"On{{Name}}",
"{{topic}}",
sub,
handlers.On{{Name}},
)
// TODO: Register additional event handlers here
}
// {{Name}}Event is the event payload DTO — protocol-specific, not a domain object.
type {{Name}}Event struct {
// TODO: Add event fields matching the publisher's payload
// Example:
// UUID string `json:"uuid"`
// Hour time.Time `json:"hour"`
}
func (h EventHandlers) On{{Name}}(msg *message.Message) error {
var event {{Name}}Event
if err := json.Unmarshal(msg.Payload, &event); err != nil {
return err
}
// TODO: Construct command and delegate to app layer
// return h.app.Commands.{{command}}.Handle(msg.Context(), command.{{command}}{
// // Map event fields to command fields
// })
return nil
}
```
## Update `main.go`
Add `WithWatermillRouter` to the unified server and include it in `OnShutdown`:
```go
server.New(
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
ports.RegisterEventHandlers(r, sub, application)
}),
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(application), router)
}),
server.OnShutdown(
server.Stop("events"), // 1. stop consuming first
server.Stop("api"), // 2. then drain HTTP
server.StopFunc(cleanup), // 3. then close clients
),
).Run(ctx)
```
## Update `docker-compose.yml`
Add `AMQP_URI` to the service environment (no separate container needed — all transports run in one process):
```yaml
{{service}}:
environment:
AMQP_URI: amqp://guest:guest@rabbitmq:5672/
depends_on:
- rabbitmq
```
@@ -0,0 +1,128 @@
# Event Publisher Adapter Scaffold Template
Generate a Watermill publisher adapter that implements a domain/app-layer interface. The adapter lives in `adapters/` and translates domain operations into published messages. The interface lives in `app/command/services.go`.
## Placeholders
- `{{Name}}` — PascalCase aggregate name (e.g., `Training`)
- `{{name}}` — camelCase (e.g., `training`)
- `{{name_snake}}` — snake_case (e.g., `training`)
- `{{name_lower}}` — all lowercase (e.g., `training`)
- `{{module}}` — Go module path from go.mod
- `{{event}}` — PascalCase first event name (e.g., `TrainingScheduled`)
- `{{topic}}` — Dot-notation topic (e.g., `training.scheduled`)
## File 1: `app/command/services.go`
If this file already exists, add the interface. Otherwise create it:
```go
package command
import "context"
// {{Name}}EventPublisher defines events that can be emitted for {{name_lower}} operations.
// Implemented by adapters (e.g., Watermill AMQP adapter).
type {{Name}}EventPublisher interface {
{{event}}(ctx context.Context) error
// TODO: Add more event methods as needed
// Example:
// {{Name}}Cancelled(ctx context.Context, uuid string) error
}
```
## File 2: `adapters/{{name_snake}}_event_publisher.go`
```go
package adapters
import (
"context"
"encoding/json"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
)
type Watermill{{Name}}EventPublisher struct {
pub message.Publisher
}
func NewWatermill{{Name}}EventPublisher(pub message.Publisher) Watermill{{Name}}EventPublisher {
return Watermill{{Name}}EventPublisher{pub: pub}
}
// {{event}}Event is the wire format for the {{topic}} topic.
type {{event}}Event struct {
// TODO: Add event payload fields
// Example:
// UUID string `json:"uuid"`
// Hour time.Time `json:"hour"`
}
func (p Watermill{{Name}}EventPublisher) {{event}}(ctx context.Context) error {
event := {{event}}Event{
// TODO: Map domain data to event fields
}
payload, err := json.Marshal(event)
if err != nil {
return err
}
msg := message.NewMessage(watermill.NewUUID(), payload)
middleware.SetCorrelationID(watermill.NewUUID(), msg)
return p.pub.Publish("{{topic}}", msg)
}
```
## Update `service/application.go`
Wire the publisher adapter in the composition root:
```go
func NewApplication(ctx context.Context) (app.Application, func()) {
// ... existing clients ...
publisher, closePub, err := client.NewWatermillPublisher()
if err != nil { panic(err) }
eventPublisher := adapters.NewWatermill{{Name}}EventPublisher(publisher)
return newApplication(ctx, eventPublisher),
func() {
// ... existing cleanup ...
_ = closePub()
}
}
```
Update the private `newApplication` to accept the publisher interface:
```go
func newApplication(
ctx context.Context,
eventPublisher command.{{Name}}EventPublisher,
// ... existing deps ...
) app.Application {
// ... pass eventPublisher to command handlers that need it
}
```
## Update command handler
Inject the publisher into the command handler that triggers the event:
```go
type {{name}}Handler struct {
{{name_lower}}Repo {{name_lower}}.Repository
eventPublisher command.{{Name}}EventPublisher
}
func (h {{name}}Handler) Handle(ctx context.Context, cmd {{command}}) error {
// ... domain logic ...
return h.eventPublisher.{{event}}(ctx)
}
```
+124
View File
@@ -0,0 +1,124 @@
# Query Handler Scaffold Template
Generate a query handler file with a read model interface.
## Placeholders
- `{{Name}}` — PascalCase query name (e.g., `AvailableHours`)
- `{{name}}` — camelCase (e.g., `availableHours`)
- `{{name_snake}}` — snake_case (e.g., `available_hours`)
- `{{module}}` — Go module path from go.mod
- `{{Result}}` — Result type (e.g., `[]Date`, `*HourDetails`)
## File: `app/query/{{name_snake}}.go`
```go
package query
import (
"context"
"github.com/sirupsen/logrus"
"{{module_common}}/decorator"
)
// Read model — defines what data the query needs
// Implemented by adapters (repository or dedicated read store)
type {{Name}}ReadModel interface {
{{Name}}(ctx context.Context /* TODO: add query params */) ({{Result}}, error)
}
// 1. Query struct — noun phrase, plain data
type {{Name}} struct {
// TODO: Add query parameters
// Example:
// From time.Time
// To time.Time
}
// Result types — optimized for reading, may differ from domain entities
// type Date struct {
// Date time.Time
// Hours []Hour
// }
// 2. Exported handler type alias
type {{Name}}Handler decorator.QueryHandler[{{Name}}, {{Result}}]
// 3. Unexported concrete handler struct
type {{name}}Handler struct {
readModel {{Name}}ReadModel
}
// 4. Constructor with nil-checks + decorator wrapping
func New{{Name}}Handler(
readModel {{Name}}ReadModel,
logger *logrus.Entry,
metricsClient decorator.MetricsClient,
) {{Name}}Handler {
if readModel == nil {
panic("nil readModel")
}
if logger == nil {
panic("nil logger")
}
if metricsClient == nil {
panic("nil metricsClient")
}
return decorator.ApplyQueryDecorators[{{Name}}, {{Result}}](
{{name}}Handler{readModel: readModel},
logger,
metricsClient,
)
}
// Handle — delegates to read model, may add input validation
func (h {{name}}Handler) Handle(ctx context.Context, q {{Name}}) ({{Result}}, error) {
// TODO: Add input validation if needed
// Example:
// if q.From.After(q.To) {
// return nil, errors.NewIncorrectInputError("date-from-after-date-to", "date from is after date to")
// }
return h.readModel.{{Name}}(ctx /* TODO: pass query params */)
}
```
## Update `app/app.go`
Add to the `Queries` struct:
```go
type Queries struct {
// ... existing handlers ...
{{Name}} query.{{Name}}Handler
}
```
## Update `service/application.go`
Wire the handler. The read model is typically implemented by the same repository adapter or a dedicated read adapter:
```go
Queries: app.Queries{
// ... existing handlers ...
{{Name}}: query.New{{Name}}Handler(
{{entity}}Repository, // implements {{Name}}ReadModel
logger,
metricsClient,
),
},
```
## Implement ReadModel on Adapter
Add the read model method to your repository adapter:
```go
// In adapters/
func (r *Memory{{Entity}}Repository) {{Name}}(ctx context.Context /* params */) ({{Result}}, error) {
// TODO: Implement query against storage
}
```
+211
View File
@@ -0,0 +1,211 @@
# Repository Scaffold Template
Generate a repository interface in the domain package and a memory implementation in adapters.
## Placeholders
- `{{Name}}` — PascalCase entity name (e.g., `Training`)
- `{{name}}` — camelCase (e.g., `training`)
- `{{name_lower}}` — all lowercase package name (e.g., `training`)
- `{{name_snake}}` — snake_case (e.g., `training`)
- `{{module}}` — Go module path from go.mod
## File: `domain/{{name_lower}}/repository.go`
```go
package {{name_lower}}
import "context"
// Repository defines persistence operations for {{Name}}.
// Defined in domain — adapters implement it implicitly.
type Repository interface {
// Get{{Name}} retrieves a {{Name}} by its UUID.
Get{{Name}}(ctx context.Context, uuid string) (*{{Name}}, error)
// Save{{Name}} loads a {{Name}}, applies the update function within a
// transaction, and persists the result. The callback pattern ensures
// domain logic is separated from transaction management.
Save{{Name}}(ctx context.Context, uuid string,
updateFn func(t *{{Name}}) (*{{Name}}, error)) error
// TODO: Add other methods as needed. Examples:
// Delete{{Name}}(ctx context.Context, uuid string) error
}
```
## File: `adapters/memory_{{name_snake}}_repository.go`
```go
package adapters
import (
"context"
"sync"
"{{module}}/domain/{{name_lower}}"
)
// Memory{{Name}}Repository is an in-memory implementation of {{name_lower}}.Repository.
// Useful for tests and local development.
type Memory{{Name}}Repository struct {
{{name}}s map[string]{{name_lower}}.{{Name}}
mu sync.RWMutex
}
func NewMemory{{Name}}Repository() *Memory{{Name}}Repository {
return &Memory{{Name}}Repository{
{{name}}s: make(map[string]{{name_lower}}.{{Name}}),
}
}
func (r *Memory{{Name}}Repository) Get{{Name}}(ctx context.Context, uuid string) (*{{name_lower}}.{{Name}}, error) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.{{name}}s[uuid]
if !ok {
return nil, {{name_lower}}.ErrNotFound
}
// Return a copy to prevent mutation of stored value
return &t, nil
}
func (r *Memory{{Name}}Repository) Update{{Name}}(
ctx context.Context,
uuid string,
updateFn func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error),
) error {
r.mu.Lock()
defer r.mu.Unlock()
current, ok := r.{{name}}s[uuid]
if !ok {
return {{name_lower}}.ErrNotFound
}
updated, err := updateFn(&current)
if err != nil {
return err
}
r.{{name}}s[uuid] = *updated
return nil
}
// Save{{Name}} stores a new {{Name}}. Used for initial creation.
func (r *Memory{{Name}}Repository) Save{{Name}}(ctx context.Context, t *{{name_lower}}.{{Name}}) error {
r.mu.Lock()
defer r.mu.Unlock()
r.{{name}}s[t.UUID()] = *t
return nil
}
```
## File: `adapters/memory_{{name_snake}}_repository_test.go`
```go
package adapters_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"{{module}}/adapters"
"{{module}}/domain/{{name_lower}}"
)
func TestMemory{{Name}}Repository_Get(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
// Setup: create and save a {{name_lower}}
entity, err := {{name_lower}}.New{{Name}}("test-uuid")
require.NoError(t, err)
err = repo.Save{{Name}}(ctx, entity)
require.NoError(t, err)
// Test: retrieve it
got, err := repo.Get{{Name}}(ctx, "test-uuid")
assert.NoError(t, err)
assert.Equal(t, "test-uuid", got.UUID())
}
func TestMemory{{Name}}Repository_GetNotFound(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
_, err := repo.Get{{Name}}(ctx, "nonexistent")
assert.ErrorIs(t, err, {{name_lower}}.ErrNotFound)
}
func TestMemory{{Name}}Repository_Update(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := adapters.NewMemory{{Name}}Repository()
// Setup
entity, err := {{name_lower}}.New{{Name}}("test-uuid")
require.NoError(t, err)
err = repo.Save{{Name}}(ctx, entity)
require.NoError(t, err)
// Test: update via callback
err = repo.Update{{Name}}(ctx, "test-uuid", func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error) {
// TODO: Apply domain action
return t, nil
})
assert.NoError(t, err)
}
```
## Extending to Production Adapters
When adding a real database adapter (e.g., PostgreSQL):
### 1. Create DB model struct
```go
// adapters/postgres_{{name_snake}}_repository.go
type postgres{{Name}} struct {
UUID string `db:"uuid"`
CreatedAt time.Time `db:"created_at"`
// ... map all persisted fields
}
```
### 2. Implement conversion methods
```go
func (r *Postgres{{Name}}Repository) to{{Name}}(m postgres{{Name}}) *{{name_lower}}.{{Name}} {
return {{name_lower}}.Unmarshal{{Name}}FromDatabase(m.UUID, m.CreatedAt)
}
```
### 3. Run shared tests against all implementations
```go
type TestRepository struct {
Name string
Repository {{name_lower}}.Repository
}
func createRepositories(t *testing.T) []TestRepository {
return []TestRepository{
{Name: "memory", Repository: adapters.NewMemory{{Name}}Repository()},
{Name: "postgres", Repository: newPostgresRepository(t)},
}
}
```
@@ -0,0 +1,258 @@
# Service Scaffold Template
Generate a complete service skeleton with all standard directories and stub files.
## Placeholders
- `{{Name}}` — PascalCase service/aggregate name (e.g., `Training`)
- `{{name}}` — camelCase (e.g., `training`)
- `{{name_snake}}` — snake_case (e.g., `training`)
- `{{name_lower}}` — all lowercase (e.g., `training`)
- `{{module}}` — Go module path from go.mod
## Files to Create
### 1. `domain/{{name_lower}}/{{name_snake}}.go`
```go
package {{name_lower}}
import (
"errors"
"time"
)
type {{Name}} struct {
uuid string
createdAt time.Time
}
func New{{Name}}(uuid string) (*{{Name}}, error) {
if uuid == "" {
return nil, errors.New("empty {{name_lower}} uuid")
}
return &{{Name}}{
uuid: uuid,
createdAt: time.Now(),
}, nil
}
func Unmarshal{{Name}}FromDatabase(uuid string, createdAt time.Time) *{{Name}} {
return &{{Name}}{
uuid: uuid,
createdAt: createdAt,
}
}
func (t {{Name}}) UUID() string {
return t.uuid
}
func (t {{Name}}) CreatedAt() time.Time {
return t.createdAt
}
```
### 2. `domain/{{name_lower}}/repository.go`
```go
package {{name_lower}}
import "context"
type Repository interface {
Get{{Name}}(ctx context.Context, uuid string) (*{{Name}}, error)
Update{{Name}}(ctx context.Context, uuid string,
updateFn func(t *{{Name}}) (*{{Name}}, error)) error
}
```
### 3. `domain/{{name_lower}}/errors.go`
```go
package {{name_lower}}
import "errors"
var (
ErrNotFound = errors.New("{{name_lower}} not found")
)
```
### 4. `app/app.go`
```go
package app
import (
"{{module}}/app/command"
"{{module}}/app/query"
)
type Application struct {
Commands Commands
Queries Queries
}
type Commands struct {
// Add command handlers here, e.g.:
// Create{{Name}} command.Create{{Name}}Handler
}
type Queries struct {
// Add query handlers here, e.g.:
// {{Name}}ByUUID query.{{Name}}ByUUIDHandler
}
```
### 5. `app/command/.gitkeep`
Create empty directory placeholder.
### 6. `app/query/.gitkeep`
Create empty directory placeholder.
### 7. `ports/http.go`
```go
package ports
import (
"{{module}}/app"
)
type HttpServer struct {
app app.Application
}
func NewHttpServer(application app.Application) HttpServer {
return HttpServer{app: application}
}
```
### 8. `main.go`
```go
package main
import (
"context"
"net/http"
"{{module_common}}/logs"
"{{module_common}}/server"
"{{module}}/ports"
"{{module}}/service"
"github.com/go-chi/chi/v5"
)
func main() {
logs.Init()
ctx := context.Background()
app := service.NewApplication(ctx)
server.New(
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
}),
server.OnShutdown(
server.Stop("api"),
),
).Run(ctx)
}
```
### 9. `adapters/memory_{{name_snake}}_repository.go`
```go
package adapters
import (
"context"
"sync"
"{{module}}/domain/{{name_lower}}"
)
type Memory{{Name}}Repository struct {
{{name_lower}}s map[string]{{name_lower}}.{{Name}}
mu sync.RWMutex
}
func NewMemory{{Name}}Repository() *Memory{{Name}}Repository {
return &Memory{{Name}}Repository{
{{name_lower}}s: make(map[string]{{name_lower}}.{{Name}}),
}
}
func (r *Memory{{Name}}Repository) Get{{Name}}(ctx context.Context, uuid string) (*{{name_lower}}.{{Name}}, error) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.{{name_lower}}s[uuid]
if !ok {
return nil, {{name_lower}}.ErrNotFound
}
return &t, nil
}
func (r *Memory{{Name}}Repository) Update{{Name}}(
ctx context.Context,
uuid string,
updateFn func(t *{{name_lower}}.{{Name}}) (*{{name_lower}}.{{Name}}, error),
) error {
r.mu.Lock()
defer r.mu.Unlock()
current, ok := r.{{name_lower}}s[uuid]
if !ok {
return {{name_lower}}.ErrNotFound
}
updated, err := updateFn(&current)
if err != nil {
return err
}
r.{{name_lower}}s[uuid] = *updated
return nil
}
```
### 10. `service/application.go`
```go
package service
import (
"context"
"{{module}}/adapters"
"{{module}}/app"
)
func NewApplication(ctx context.Context) app.Application {
{{name_lower}}Repository := adapters.NewMemory{{Name}}Repository()
_ = {{name_lower}}Repository // wire into handlers
return app.Application{
Commands: app.Commands{},
Queries: app.Queries{},
}
}
```
## Post-Creation Instructions
After creating the service skeleton:
1. Ensure unified server exists: `/3dl scaffold unified_server`
2. Add your first command with `/3dl scaffold command <ActionName>`
3. Add your first query with `/3dl scaffold query <QueryName>`
4. Wire them in `service/application.go`
5. Add HTTP/gRPC handlers in `ports/`
6. When adding Watermill: `/3dl scaffold watermill_router` then `/3dl scaffold event_handler <Name>`
@@ -0,0 +1,297 @@
# Unified Server Scaffold Template
Generate the core unified server infrastructure in `internal/common/server/`. This replaces the standalone `RunHTTPServer` / `RunGRPCServer` functions with a composable `server.New(...).Run(ctx)` pattern that supports multiple transports with explicit shutdown ordering.
Created once per project. Individual transports (`WithWatermillRouter`) can be added later.
## Placeholders
- `{{module_common}}` — Go module path to `internal/common` (e.g., `github.com/example/myproject/internal/common`)
## File 1: `internal/common/server/server.go`
```go
package server
import (
"context"
"os/signal"
"sort"
"sync"
"syscall"
"time"
"github.com/sirupsen/logrus"
)
type Server struct {
components map[string]component
startOrder []string
shutdownSteps []ShutdownStep
}
type component struct {
name string
start func(ctx context.Context) error
stop func(ctx context.Context) error
}
type Option func(*Server)
func New(opts ...Option) *Server {
s := &Server{
components: make(map[string]component),
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Server) addComponent(name string, c component) {
if _, exists := s.components[name]; exists {
panic("duplicate component name: " + name)
}
s.components[name] = c
s.startOrder = append(s.startOrder, name)
}
func (s *Server) Run(ctx context.Context) error {
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, len(s.components))
for _, name := range s.startOrder {
c := s.components[name]
go func(c component) {
logrus.WithField("component", c.name).Info("Starting")
if err := c.start(ctx); err != nil {
errCh <- err
}
}(c)
}
select {
case <-ctx.Done():
logrus.Info("Shutdown signal received")
case err := <-errCh:
logrus.WithError(err).Error("Component failed, initiating shutdown")
}
s.executeShutdown()
return nil
}
func (s *Server) executeShutdown() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stopped := map[string]bool{}
for _, step := range s.shutdownSteps {
if step.fn != nil {
logrus.Info("Running shutdown func")
if err := step.fn(shutdownCtx); err != nil {
logrus.WithError(err).Error("Shutdown func failed")
}
continue
}
var wg sync.WaitGroup
for _, name := range step.componentNames {
c, ok := s.components[name]
if !ok {
logrus.WithField("component", name).Warn("Unknown component in OnShutdown")
continue
}
stopped[name] = true
wg.Add(1)
go func(c component) {
defer wg.Done()
logrus.WithField("component", c.name).Info("Stopping")
if err := c.stop(shutdownCtx); err != nil {
logrus.WithError(err).WithField("component", c.name).Error("Stop failed")
}
}(c)
}
wg.Wait()
}
// Safety net: stop any components not mentioned in OnShutdown
var wg sync.WaitGroup
for name, c := range s.components {
if stopped[name] {
continue
}
wg.Add(1)
go func(c component) {
defer wg.Done()
logrus.WithField("component", c.name).Warn("Stopping (not in OnShutdown — add it)")
if err := c.stop(shutdownCtx); err != nil {
logrus.WithError(err).WithField("component", c.name).Error("Stop failed")
}
}(c)
}
wg.Wait()
}
```
## File 2: `internal/common/server/shutdown.go`
```go
package server
import "context"
// ShutdownStep is one step in the shutdown sequence.
type ShutdownStep struct {
componentNames []string
fn func(ctx context.Context) error
}
// Stop creates a shutdown step that stops named components.
// Multiple names in one call = parallel shutdown within the step.
func Stop(names ...string) ShutdownStep {
return ShutdownStep{componentNames: names}
}
// StopFunc creates a shutdown step that runs an arbitrary cleanup function.
func StopFunc(fn func()) ShutdownStep {
return ShutdownStep{
fn: func(ctx context.Context) error {
fn()
return nil
},
}
}
// StopFuncWithErr creates a shutdown step with error return.
func StopFuncWithErr(fn func(ctx context.Context) error) ShutdownStep {
return ShutdownStep{fn: fn}
}
// OnShutdown declares the shutdown sequence.
// Steps execute top-to-bottom. Each step completes before the next starts.
// Components not mentioned are stopped last with a warning.
func OnShutdown(steps ...ShutdownStep) Option {
return func(s *Server) {
s.shutdownSteps = steps
}
}
```
## File 3: `internal/common/server/http.go` (replace existing)
```go
package server
import (
"context"
"net/http"
"os"
"{{module_common}}/auth"
"{{module_common}}/logs"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/sirupsen/logrus"
)
func WithHTTPHandler(name string, createHandler func(chi.Router) http.Handler) Option {
return func(s *Server) {
addr := ":" + os.Getenv("PORT")
srv := &http.Server{Addr: addr}
s.addComponent(name, component{
name: name,
start: func(ctx context.Context) error {
apiRouter := chi.NewRouter()
setMiddlewares(apiRouter)
rootRouter := chi.NewRouter()
rootRouter.Mount("/api", createHandler(apiRouter))
srv.Handler = rootRouter
logrus.WithField("addr", addr).Info("Starting HTTP server")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
return err
}
return nil
},
stop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
}
}
// setMiddlewares, addAuthMiddleware, addCorsMiddleware — same as existing
```
## File 4: `internal/common/server/grpc.go` (replace existing)
```go
package server
import (
"context"
"net"
"os"
"{{module_common}}/logs"
grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
grpc_logrus "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus"
grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
)
func WithGRPCServer(name string, registerServer func(*grpc.Server)) Option {
return func(s *Server) {
logrusEntry := logrus.NewEntry(logrus.StandardLogger())
grpcSrv := grpc.NewServer(
grpc_middleware.WithUnaryServerChain(
grpc_ctxtags.UnaryServerInterceptor(grpc_ctxtags.WithFieldExtractor(grpc_ctxtags.CodeGenRequestFieldExtractor)),
grpc_logrus.UnaryServerInterceptor(logrusEntry),
),
grpc_middleware.WithStreamServerChain(
grpc_ctxtags.StreamServerInterceptor(grpc_ctxtags.WithFieldExtractor(grpc_ctxtags.CodeGenRequestFieldExtractor)),
grpc_logrus.StreamServerInterceptor(logrusEntry),
),
)
registerServer(grpcSrv)
port := os.Getenv("GRPC_PORT")
if port == "" {
port = "8080"
}
addr := ":" + port
s.addComponent(name, component{
name: name,
start: func(ctx context.Context) error {
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
logrus.WithField("addr", addr).Info("Starting gRPC server")
return grpcSrv.Serve(lis)
},
stop: func(ctx context.Context) error {
grpcSrv.GracefulStop()
return nil
},
})
}
}
```
## Post-Creation Instructions
After creating the unified server:
1. Remove or replace the old `RunHTTPServer` / `RunGRPCServer` standalone functions
2. Update all `main.go` files to use `server.New(...).Run(ctx)` with `OnShutdown`
3. Add `/threedotslabs scaffold watermill_router` to add Watermill support
4. Every component MUST appear in `OnShutdown` — the safety net logs warnings for forgotten ones
@@ -0,0 +1,116 @@
# Watermill Router Option + Publisher Client Scaffold Template
Generate the `WithWatermillRouter` server option in `internal/common/server/` and the publisher client factory in `internal/common/client/`. Requires the unified server scaffold (`/threedotslabs scaffold unified_server`) to be in place first.
## Placeholders
- `{{module_common}}` — Go module path to `internal/common` (e.g., `github.com/example/myproject/internal/common`)
## File 1: `internal/common/server/watermill.go`
```go
package server
import (
"context"
"os"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill-amqp/v3/pkg/amqp"
"github.com/ThreeDotsLabs/watermill/message"
wmMiddleware "github.com/ThreeDotsLabs/watermill/message/router/middleware"
)
func WithWatermillRouter(
name string,
configure func(*message.Router, message.Subscriber),
) Option {
return func(s *Server) {
wmLogger := watermill.NewStdLoggerWithOut(os.Stdout, true, false)
amqpURI := os.Getenv("AMQP_URI")
if amqpURI == "" {
amqpURI = "amqp://guest:guest@rabbitmq:5672/"
}
amqpConfig := amqp.NewDurableQueueConfig(amqpURI)
sub, err := amqp.NewSubscriber(amqpConfig, wmLogger)
if err != nil {
panic("cannot create watermill subscriber: " + err.Error())
}
r, err := message.NewRouter(message.RouterConfig{}, wmLogger)
if err != nil {
panic("cannot create watermill router: " + err.Error())
}
r.AddMiddleware(
wmMiddleware.CorrelationID,
wmMiddleware.Recoverer,
wmMiddleware.Retry{MaxRetries: 3}.Middleware,
)
configure(r, sub)
s.addComponent(name, component{
name: name,
start: func(ctx context.Context) error {
return r.Run(ctx)
},
stop: func(ctx context.Context) error {
return r.Close()
},
})
}
}
```
## File 2: `internal/common/client/watermill.go`
```go
package client
import (
"os"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill-amqp/v3/pkg/amqp"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
func NewWatermillPublisher() (pub message.Publisher, close func() error, err error) {
amqpURI := os.Getenv("AMQP_URI")
if amqpURI == "" {
return nil, func() error { return nil }, errors.New("empty env AMQP_URI")
}
logger := watermill.NewStdLoggerWithOut(os.Stdout, true, false)
config := amqp.NewDurableQueueConfig(amqpURI)
publisher, err := amqp.NewPublisher(config, logger)
if err != nil {
return nil, func() error { return nil }, errors.Wrap(err, "cannot create watermill publisher")
}
return publisher, publisher.Close, nil
}
```
## Post-Creation Instructions
After creating the Watermill option and publisher:
1. Add `github.com/ThreeDotsLabs/watermill` and `github.com/ThreeDotsLabs/watermill-amqp/v3` to `go.mod`
2. Add `AMQP_URI` to `.env`, `.test.env`, and `docker-compose.yml`
3. Add a RabbitMQ service to `docker-compose.yml`:
```yaml
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
```
4. Use `/3dl scaffold event_handler <Name>` to create event handlers in a service
5. Use `/3dl scaffold event_publisher <Name>` to create a publisher adapter
6. Add `server.WithWatermillRouter("events", ...)` and include `"events"` in `OnShutdown`
+10
View File
@@ -0,0 +1,10 @@
{
"name": "tea",
"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", "login-guard"]
}
+228
View File
@@ -0,0 +1,228 @@
# AGENTS.md
## Project goals
1. **Unify and systematize issue workflow** for the development team with
minimal context usage. Issue operations are wrapped in scripts so agents
spend tokens on the task, not on re-deriving commands and formats.
2. **Keep the tracker out of the work.** An issue is a unit of work first and a
Gitea row second. The two are separate layers, and the first one does not
know the second exists.
3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of
direct ad-hoc calls wherever possible. Scripts give deterministic,
reviewable behavior; the `tea-guard` hook enforces that every `tea`
invocation runs under the operator-pinned login.
## Layers
The hard rule of this repo. One domain, one bridge, one transport, and
knowledge flows one way only:
```
skills/issue DOMAIN what an issue is: format, validation, dependency graph
▲ 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
│ imports
skills/auth IDENTITY pin the login the whole tracker side runs under
▲ pin.py where the pin is and how it is found —
│ imports imported by _gitea.py AND by hooks/tea-guard.sh
hooks/tea-guard so `tea` and the scripts cannot disagree
skills/use REFERENCE tea CLI docs for everything that is not an issue
│ calls
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
```
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
```
If a tracker concept (issue number, login, HTTP call, label color) shows up in
the domain layer, it is in the wrong place.
## Repo layout
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
- `scripts/pin.py` — the one written copy of the pin's location and search
order (see "The login pin" below); stdlib, no subprocess, no network
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
- `references/format.md` — canonical issue format; single source of truth
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
taxonomy, dependency graph, body checkboxes
- `scripts/issue_new.py` — create a local issue from its type template
- `scripts/issue_check.py` — validate against the format
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
substring, changing exactly one character of the file
- `scripts/issue_tree.py` — draw the dependency graph
- `scripts/issue_evict.py` — remove closed issues from the store; never an
`origin: local` one
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
- `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids,
the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py`
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
- `scripts/close.py` — the state field, both ways; explicit ids only
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
the domain's `issue_evict.run`
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
repository; reads the domain taxonomy, never the store
- `skills/use``tea` CLI reference for everything that is not an issue
(`/tea:use`); `references/tea/` holds the command docs
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
returns a compact receipt. Delegate batches (bulk pull, push a named set,
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
and no `Write`, may not `--force`, and may not decide what an issue says.
Delegating a single call costs more than running it inline — the win is the
loop, the retry, and the error triage.
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
that don't use the pinned login (resolving it through `auth/pin.py`);
`agents-sync` keeps every directory canonical (`AGENTS.md` real file,
`CLAUDE.md` symlink to it)
- `tests/` — stdlib `unittest`, no third-party anything
## The login pin
`<project root>/.claude/settings.local.json``env.GITEA_LOGIN`, written by
`/tea:auth` and read at call time. **The search order is written once, in
`skills/auth/scripts/pin.py`**, and both callers import it: the transport
(`_gitea.require_login`) and the `tea-guard` hook. Neither spells the path or
the walk itself, and a test asserts they don't.
Start directories, first hit wins: `$CLAUDE_PROJECT_DIR`, then a hint the
caller supplies (the hook passes the Bash payload's `cwd`; a script passes
nothing), then the current directory. Each one is searched up its parent chain,
and then — only if that found nothing — up the parent chain of the **main
working tree of any linked worktree** met on the way, reached by reading
`gitdir:` out of a `.git` *file* and following `commondir`.
**The pin is not resolved from `__file__`, and that asymmetry with
`issue.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.**
Where an installation keeps its files is a fact about the installation; whose
login a project runs under is a fact about the project. A plugin installed
outside any repository and pointed at somebody else's tree must not answer the
second question from its own directory. So the search runs from the working
directory upward — and reaches a worktree's main checkout by asking git.
Two failures this replaces, both worth remembering: a git worktree is a
*sibling* of the main checkout, so the untracked pin is not on its parent chain
and the whole sync layer died there while `tea` in the same directory worked;
and the cure it invited — `/tea:auth` inside the worktree — writes a second
settings file into a directory that is deleted with the worktree.
## Tests
```bash
python3 -m unittest discover -s tests -v
```
Plain `unittest`; no pytest, no dependencies — the scripts under test are
stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
packages, so a test that needs the domain module imports it with
`sys.path.insert`.
**A test never touches `tmp/issues/` or `tmp/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.
`tmp/payload/` is in that list because `_gitea.PAYLOAD_ROOT` is resolved once,
from the module's own location: a test that stubs the transport *below* `api()`
— at `subprocess`, to exercise a non-2xx — reaches the real write. Such a test
patches `PAYLOAD_ROOT` to its own temp directory too.
## Local issue store
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
store.** An `origin: local` issue lives here and nowhere else — this file *is*
the issue, and losing it loses the work. Anything with `origin: gitea` is a
**cache**: the tracker has it, this copy is a working copy, and it is deleted
the moment a push confirms the tracker is up to date.
One flat markdown file per issue, named by its slug, with one metadata field per
line so plain grep works without a parser.
- **The path is `<repo root>/tmp/issues`, resolved from `issue.py`'s own
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
whatever directory it is run from. An explicit `--out` overrides it and is
used exactly as typed; a relative `--out` stays relative to cwd.
- Nothing creates the store as a side effect of a write. Readers distinguish
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
and they say so on stderr.
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
Numbers live in the `gitea:` field.
- `origin: local` is a complete state, not a draft: an issue that never leaves
this machine is valid and finished. It is not a *durable* state, though —
pushing ends it, and the local file goes with it.
- **A successful push deletes the local file** (`<id>.md` and
`<id>.comments.md`), and prints the number and URL the issue now lives at.
`--update` too: one rule, no exception. What is in the store is what has not
left. Get it back with `pull.py <n>` — which brings its blockers back with it:
a pull returns the unit of work, not one row of it. `--no-deps` narrows it to
the one issue, and the cost of the default is in `pull.py`'s docstring.
- Deletion happens only after a confirmed tracker response and only after
`.remote.json` has been written. Network down, non-2xx, an answer that does
not carry the right number: the file stays and the run stops. A never-pushed
`origin: local` issue is never touched by any of this.
- The slug survives the round trip because it goes up in the body as
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
fresh clone, another machine — the file comes back under the same name and
every `depends:` that points at it still resolves.
- `.remote.json` is therefore no longer "an index over the files": it is the
local number → slug ledger, its entries outlive the files they name, and
nothing prunes them. It is still recoverable — from the markers in Gitea, not
from the files. **Eviction does not prune it either**, for the same reason a
push does not: an evicted issue is in exactly the state a pushed one is.
- **A closed issue is evicted, not archived.** `issue_evict.py` removes
`<id>.md` and every sidecar under that slug for anything that is `state:
closed` **and** carries an `origin:` naming a tracker, then rebuilds
`INDEX.md`. `--dry-run` prints and writes nothing. **`origin: local` is never
evicted, in any state, not even when named on the command line** — that file
*is* the issue and nothing can fetch it back.
- **Eviction lives in the domain** (`skills/issue/scripts/issue_evict.py`),
because its two inputs — `state:` and `origin:` — are domain fields and the
answer is already on disk. No network, no login, no `tea`.
`skills/sync/scripts/evict.py` is the bridge form: it refreshes `state:` from
the tracker first (a local `state:` is only as fresh as the last pull) and then
calls `issue_evict.run`. One implementation of "what may be evicted", in the
layer that owns the fields it reads. Same gate as push, one step earlier: a
failed or unconfirmed tracker answer evicts nothing at all.
- **Pull by number fetches an issue in any state — a number is a number.** An
address is not a query: `pull.py 42` puts a closed issue on disk exactly as it
always has, and so does `#42`, `owner/repo#42`, or its URL. Only filter mode
(`--milestone`, `--label`, `-q`) leaves closed issues out. Eviction does not
revoke this: a closed issue pulled after a cleanup lands on disk again, and
that is the tracker answering what it was asked, not a regression. Evict it
again when you are done with it.
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
issue comes back at all.
- No drift tracking, and now nothing to track: there is no second copy to
diverge from. `synced:` tells you how old your working copy is.
## 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, 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.
- **A scratchpad may never sit inside a store.** Store contents are the thing
being tracked; request bodies are debris of the transport. When the two share
a path, an operation that touches no issue at all still materializes the issue
store, and the operator's `ls tmp/issues` starts lying about what exists.
+187
View File
@@ -0,0 +1,187 @@
# tea — Claude Code plugin for the Gitea CLI
A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforces a hard rule: every `tea` command runs under the login **the operator chose**, never one Claude picked.
## What it ships
| Piece | What it does |
|---|---|
| `/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, 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. 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 issue JSON, then over the wire
│ calls
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
```
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
- **Claude Code** — CLI, desktop app, or IDE extension
- **Python 3** — required by the `tea-guard` hook (`python3` must be on `$PATH`)
- **`tea`** — Gitea's official CLI. Install with `brew install tea` (macOS) or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases)
- At least one login configured: `tea logins add` (interactive — run it in a terminal, not via Claude)
## Installation
This is a Claude Code plugin — install it through the plugin marketplace, not by hand-editing `settings.json`.
1. Register the marketplace this plugin ships in:
```
/plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
```
Already have a local clone? Point at the directory instead:
```
/plugin marketplace add /path/to/marketplace
```
2. Install the plugin:
```
/plugin install tea@claude-skills
```
The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later.
> The marketplace registration is written to `extraKnownMarketplaces` and the plugin to `enabledPlugins` in your settings automatically — you don't edit those by hand. There is **no** top-level `"plugins"` settings key; if you've added one from older instructions, remove it.
## First use
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to the project root's `.claude/settings.local.json` and takes effect immediately — no restart needed.
Once per *project*, not once per checkout: a `git worktree` shares its main checkout's pin. Both the hook and the scripts find it from inside a worktree, so don't run `/tea:auth` there — it would leave a second pin in a directory that disappears with the branch.
```
/tea:auth
```
After that, just ask Claude to do something with issues or Gitea — it loads the
right skill automatically. `/tea:auth` is only needed for the tracker side;
`/tea:issue` works without any login at all.
## How the login guard works
Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. The hook and the scripts look it up the same way — one search order, in `skills/auth/scripts/pin.py`.
Claude is **blocked** from:
- running `tea` without `--login` at all
- naming a login itself (e.g. `--login myaccount`)
- using any variable other than `$GITEA_LOGIN`
This prevents silent fallback to the machine's default login (often a personal account) when working in a project that belongs to a different identity.
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
## The tea-runner agent
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on
Haiku that does the second half in its own context and hands back a receipt —
what ran, what it touched, what failed, verbatim.
Delegate a **batch**: pull a milestone and rebuild the index, push the three
issues you just wrote, bootstrap the label set, post a comment from a file you
prepared. Spawning it for a single `pull.py 42` costs more than running the
command yourself; the saving is in the loop, the retry, and reading somebody
else's stderr.
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
type, a failed validation, an unpushed dependency come back as a question, not
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
session — the pinned login is enforced on every call it makes.
## Project layout
```
.claude-plugin/
plugin.json plugin manifest
(the marketplace catalog lives one level up, in
the repo root's .claude-plugin/marketplace.json)
agents/
tea-runner.md subagent (Haiku) that executes the scripts
hooks/
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/ /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:
issue.py domain module: slug identity, parse/render,
validation, taxonomy, dependency graph,
body checkboxes
issue_new.py create a local issue from its type template
issue_check.py validate against the format
issue_ac.py list the body's checkboxes; tick one
issue_tree.py draw the dependency graph
issue_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
scripts/
map.py md <-> Gitea JSON, pure functions, no I/O
_gitea.py transport: login pin, tea api, pagination, filters
pull.py Gitea -> tmp/issues/
push.py tmp/issues/ -> Gitea, then drops the local file
remote.py discovery listing to stdout
comment.py post or edit a comment
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
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
a parser.
An `origin: local` file **is** the issue — the store, and the only copy.
Anything with `origin: gitea` is a working copy of something the tracker
already has, and it is deleted as soon as a push confirms the tracker is up to
date:
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
live in a `gitea:` field.
- `origin: local` is a complete state. An issue that never leaves your machine
is valid and finished — but it is not permanent: pushing ends it.
- **A successful push deletes the local file** (`--update` too) and prints the
number and URL it now lives at. Only after a confirmed response: a failed
call leaves the file exactly where it was. Get it back with `pull.py <n>` —
same slug, same `depends:`, even after a rename in Gitea.
- Pulling overwrites the body: a fetch, not a merge. It is also how a pushed
issue comes back.
- Nothing tracks drift, and there is no second copy to drift. A file that is
still here has not been pushed.
@@ -29,11 +29,10 @@ to fill the gap yourself.
Load the skill, do not remember the flags: Load the skill, do not remember the flags:
- `/tea:sync``pull.py`, `push.py`, `comment.py`, `remote.py`, `labels.py` - `/tea:sync``pull.py`, `push.py`, `comment.py`, `close.py`, `remote.py`,
`labels.py`, `evict.py`
- `/tea:issue``issue_check.py`, `issue_tree.py`, `issue_index.py`, - `/tea:issue``issue_check.py`, `issue_tree.py`, `issue_index.py`,
`issue_new.py`, `issue_ac.py` `issue_new.py`, `issue_ac.py`, `issue_evict.py`
- `/tea:wiki``wiki_ls.py`, `wiki_pull.py`, `wiki_push.py`
- `/tea:page``page_import.py`, `page_index.py`, `page_ls.py`
Invoke `Skill` with the one that owns the task at the start, and use the command Invoke `Skill` with the one that owns the task at the start, and use the command
table it gives you verbatim. The skill is the single source of table it gives you verbatim. The skill is the single source of
@@ -44,8 +43,7 @@ instead of trying it.
## Hard rules ## Hard rules
1. **No raw `tea`.** Every tracker call goes through a script in 1. **No raw `tea`.** Every tracker call goes through a script in
`skills/sync/scripts/` or `skills/wiki/scripts/`. The one exception is a `skills/sync/scripts/`. The one exception is a diagnostic the skill itself
diagnostic the skill itself
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder — documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
the `tea-guard` hook substitutes the pinned login. Never name a login. the `tea-guard` hook substitutes the pinned login. Never name a login.
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts 2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
@@ -55,20 +53,29 @@ instead of trying it.
only the items the caller named, by the number or the substring the caller only the items the caller named, by the number or the substring the caller
gave. Whether a criterion is actually met is a judgement about content, and gave. Whether a criterion is actually met is a judgement about content, and
content is never yours. content is never yours.
3. **Push only what you were told to push.** `push.py` and `wiki_push.py` 3. **Push only what you were told to push.** `push.py` publishes to a tracker
publish to a tracker other people read, **and `push.py` deletes the local other people read, **and it deletes the local file on success** — so a
file on success** — so a widened set is not an over-share, it is somebody widened set is not an over-share, it is somebody else's working copy gone.
else's working copy gone. Run them with the ids, titles, or filter the Run it with the ids, titles, or filter the caller named. Never widen the
caller named. Never widen the set, never run a bare `push.py` because it set, never run a bare `push.py` because it looked like the obvious next
looked like the obvious next step, and never pass `--force` — a validation step, and never pass `--force` — a validation failure is a result to report,
failure is a result to report, not an obstacle to route around. not an obstacle to route around. Report the number and URL `push.py`
`wiki_push.py` needs `-m`; use the caller's words, never your own summary. printed; that is now the only address the issue has.
Report the number and URL `push.py` printed; that is now the only address 4. **Close only the ids the caller named.** Closing is a script now
the issue has. (`close.py`), so it is yours to run — under the same discipline as push: the
4. **Do not close, delete, or retitle anything** on either side. On the wiki ids the caller named, and no others. Never widen the set, never infer that
that means no `--retitle`: renaming a published page abandons the old one. an issue is finished because its checkboxes are ticked or its branch is
The one deletion you may cause is push's own, on the issue you were told to merged; whether work is done is a judgement about content, and content is
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 5. **One retry, maximum.** A command that fails twice is a finding. Do not
permute flags looking for one that works. permute flags looking for one that works.
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled 6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
@@ -122,3 +129,4 @@ Report these and halt; none of them is yours to resolve.
| a dependency is still `origin: local` | name the id; the caller decides whether to push it | | a dependency is still `origin: local` | name the id; the caller decides whether to push it |
| a milestone or label does not exist in the repo | the script prints the real ones — pass that list through | | a milestone or label does not exist in the repo | the script prints the real ones — pass that list through |
| a script asks for a decision (type, label, `--force`) | `blocked:` with the question | | a script asks for a decision (type, label, `--force`) | `blocked:` with the question |
| `close.py` is refused by Gitea because the issue is still blocked | the tracker's own line, and the blocker's number; the caller decides |
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
tea-guard — PreToolUse(Bash) hook for the `tea` plugin.
Enforces, deterministically, the one rule prose cannot: every `tea` command
that touches Gitea runs under the login the OPERATOR pinned — never one Claude
chose. It does this by *resolving and rewriting* the command rather than just
checking it:
Claude must write: tea ... --login "$GITEA_LOGIN" ...
The guard rewrites: tea ... --login <operator-pinned-login> ...
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
scripts resolve the pin through the same module. One order, one copy of it. The
guard and the scripts disagreeing about a directory is a bug by construction,
and was one: in a git worktree `tea` worked and every script said "no login
pinned".
Rules:
- not a `tea` command ............................. allow (passthrough)
- tea logins list/ls, tea --version/--help ........ allow (no identity used)
- no --login / -l ................................. BLOCK
- --login <literal> or --login "$OTHER_VAR" ....... BLOCK (Claude may not pick)
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
"A `tea` command" means the shell would RUN `tea`, not that the string contains
the word. The guard used to ask the second question — a substring search over
the whole command line — and in a repository whose subject *is* the CLI that is
a different question with the same answer far too often: an issue title, a
commit message, `grep -rn " tea " docs/` and `echo tea` were all blocked, with
a message telling the operator to add `--login` to `git commit`. Worse, the
advice was unfollowable: the only way past the guard was to reword the prose.
So the command is tokenized (heredoc bodies dropped, line continuations
folded, backticks and newlines treated as boundaries) and only words in
*command position* count — the first word, and the first word after `;`, `&&`,
`||`, `|`, `&`, `(`, `)`, `{`, `}`, past any VAR=value assignments and prefix
words like `env`/`sudo`/`xargs`. Quoting is what saves the prose: a title or a
`-m` message is one token, and one token is never a command. Compound commands
stay guarded segment by segment, substitutions included, and every `tea` in the
line is checked — not just the first.
If the line cannot be tokenized at all (unbalanced quotes), the old substring
test decides. That direction fails closed: it over-matches, and over-matching
blocks.
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
rewrite; exit 2 + stderr to block.
"""
import sys, os, re, json, shlex
# The identity layer, reached by the plugin's own layout — the one thing a hook
# may assume about where it lives. Import failure is not fatal on its own: a
# command that is not `tea` still passes through untouched (see main), and only
# a command that needs a login is blocked.
sys.path.append(os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)),
os.pardir, "skills", "auth", "scripts")))
try:
import pin
except Exception:
pin = None
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
# Operators after which the next word is a command again.
SEPARATORS = {";", ";;", "&", "&&", "|", "|&", "||", "(", ")", "{", "}"}
# Words that stand in front of a command without being one.
TRANSPARENT = {"env", "command", "exec", "nohup", "time", "sudo", "xargs",
"if", "then", "else", "elif", "while", "until", "do", "!"}
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
REDIRECT = re.compile(r"^\d*[<>]+&?\d*-?$")
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
# A login flag and its value, in the ORIGINAL text — this is what gets
# rewritten, so it works on the raw string rather than on tokens.
LOGIN_FLAG = re.compile(r"(--login|(?<![\w-])-l)(\s+|=)(\S+)")
# The pre-tokenizer test, kept for the one case tokenizing cannot serve.
LOOKS_LIKE_TEA = re.compile(r"(^|[;&|(]|\s)tea(\s|$)")
NO_LOGIN = ('every `tea` command must include --login "$GITEA_LOGIN" '
'(the guard substitutes the operator-pinned login). '
'Run /tea:auth if no login is pinned.')
def named_login(raw):
return ('do not name the login yourself (got `%s`). Write exactly '
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
'the operator pinned via /tea:auth. This prevents acting under '
'the wrong identity.' % raw)
def unquote(value):
for q in ('"', "'"):
if len(value) >= 2 and value[0] == q and value[-1] == q:
return value[1:-1]
return value
def strip_heredocs(cmd):
"""Drop heredoc bodies. They are data the shell feeds to a command, not
commands — and a commit message quoting a raw `tea api` call is exactly the
thing that used to be unwritable."""
lines, kept, i = cmd.split("\n"), [], 0
while i < len(lines):
line = lines[i]
kept.append(line)
i += 1
for m in HEREDOC.finditer(line):
delim, dash = m.group(2), m.group(0).startswith("<<-")
while i < len(lines):
probe = lines[i].strip() if dash else lines[i].rstrip()
i += 1
if probe == delim:
break
return "\n".join(kept)
def shell_words(cmd):
"""Tokens, with operators as tokens of their own and quotes honored.
Backticks and newlines become separators before tokenizing: shlex knows
neither, and both start a command. Inside quotes that substitution is
harmless — the token still spans the quotes, and a token is never a
command."""
text = strip_heredocs(cmd)
text = re.sub(r"\\\n", " ", text)
text = text.replace("`", " ; ").replace("\n", " ; ")
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
lex.whitespace_split = True
return list(lex)
def tea_invocations(words):
"""The argument list of every `tea` the shell would actually run."""
found, current, expect, skip = [], None, True, False
for w in words:
if skip:
skip = False
continue
if REDIRECT.match(w):
skip = True # the target of a redirection is not a command
continue
if w in SEPARATORS:
current, expect = None, True
continue
if expect:
if ASSIGNMENT.match(w) or w in TRANSPARENT:
continue
expect = False
if w.rsplit("/", 1)[-1] == "tea":
current = []
found.append(current)
continue
if current is not None:
current.append(w)
return found
def is_meta(args):
"""Login enumeration and `--version`/`--help`: no identity is used, and
/tea:auth needs `tea logins list` while no pin exists yet."""
if not args:
return False
if args[0] in ("--version", "-v", "--help", "-h", "help"):
return True
return args[0] in ("logins", "login") and len(args) > 1 \
and args[1] in ("list", "ls")
def login_value(args):
"""The login as written, or None if the flag is absent."""
for i, a in enumerate(args):
if a in ("--login", "-l"):
return args[i + 1] if i + 1 < len(args) else ""
if a.startswith("--login=") or a.startswith("-l="):
return a.split("=", 1)[1]
return None
def substitute(cmd, login):
"""Every placeholder login in the line, replaced by the pin. Every one:
a command may run `tea` twice, and half a rewrite leaves the second call
with an unset variable and no login at all."""
def repl(m):
if unquote(m.group(3)) in PLACEHOLDERS:
return m.group(1) + m.group(2) + shlex.quote(login)
return m.group(0)
return LOGIN_FLAG.sub(repl, cmd)
def block(msg):
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
sys.exit(2)
def allow_passthrough():
# exit 0 with no stdout → tool runs unchanged
sys.exit(0)
def rewrite(tool_input, new_cmd, note):
updated = dict(tool_input)
updated["command"] = new_cmd
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": updated,
"additionalContext": note,
}
}))
sys.exit(0)
def main():
try:
payload = json.load(sys.stdin)
except Exception:
# Can't parse the hook payload — fail open for non-tea safety, but we
# can't even read the command, so don't block arbitrary Bash.
allow_passthrough()
tool_input = payload.get("tool_input") or {}
cmd = tool_input.get("command") or ""
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
if runs is None:
if not LOOKS_LIKE_TEA.search(cmd):
allow_passthrough()
m = LOGIN_FLAG.search(cmd)
if not m:
block(NO_LOGIN)
if unquote(m.group(3)) not in PLACEHOLDERS:
block(named_login(m.group(3)))
else:
# The word appears but nothing runs it → not our concern. This is the
# branch that lets prose about the CLI be written at all.
if not runs:
allow_passthrough()
for args in runs:
if is_meta(args):
continue
raw = login_value(args)
if raw is None:
block(NO_LOGIN)
if unquote(raw) not in PLACEHOLDERS:
block(named_login(raw))
if all(is_meta(args) for args in runs):
allow_passthrough()
if pin is None:
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
'cannot be resolved. The plugin tree is incomplete; reinstall it.')
# The hint is the directory the Bash command will run in; the rest of the
# order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the
# search) is pin.py's, and is the same order the scripts get.
login, src = pin.find_pin(payload.get("cwd"))
if not login:
block('no login is pinned. Run /tea:auth to choose one (writes '
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
'the file at call time, so it takes effect with no restart.')
rewrite(tool_input, substitute(cmd, login),
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
if __name__ == "__main__":
main()
@@ -32,13 +32,37 @@ So:
3. **One login:** propose it; confirm before writing. 3. **One login:** propose it; confirm before writing.
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and 4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
`url` so the operator's choice is unambiguous. Never decide for them. `url` so the operator's choice is unambiguous. Never decide for them.
5. Merge the chosen name into `.claude/settings.local.json` under `env` 5. Merge the chosen name into the **project root's**
(do not clobber other keys): `.claude/settings.local.json` under `env` (do not clobber other keys):
```json ```json
{ "env": { "GITEA_LOGIN": "<chosen-name>" } } { "env": { "GITEA_LOGIN": "<chosen-name>" } }
``` ```
**In a git worktree, write it to the main checkout, never to the worktree.**
A worktree is deleted when the branch is done, taking a pin written into it
with it, and one repository with two pins is one repository with two
identities. Both the guard and the scripts already reach the main checkout's
pin from inside any worktree — so there is nothing to pin a second time.
`git rev-parse --path-format=absolute --git-common-dir` names the `.git` to
write beside.
6. Done — it is live. The guard resolves the pin from the file on the next 6. Done — it is live. The guard resolves the pin from the file on the next
`tea` call; no restart needed. Tell the operator which login is now pinned. `tea` call; no restart needed. Tell the operator which login is now pinned,
and which file it went in.
## Where the pin is looked for
One search order, written once in `scripts/pin.py` and imported by both the
`tea-guard` hook and the sync transport — they cannot disagree about a
directory, and a test asserts neither keeps a copy of the walk.
`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's
`cwd`), then the current directory. Each is searched up its parent chain; only
if that finds nothing does the search cross into the main working tree of a
linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory
is never a source — a plugin pointed at somebody else's project must take the
identity from that project, not from where it happens to be installed.
If a script reports "no login pinned", that is the honest answer: nothing was
found anywhere on that order. Pin one — at the project root.
## Identity-safety rules ## Identity-safety rules
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
pin.py — where the operator's Gitea login pin is, and how it is found.
**The search order lives here and nowhere else.** The `tea-guard` hook imports
this module; so does the transport every sync script runs on. Two
copies of the order is exactly how a git worktree came to have a working hook
and a dead transport in the same directory: `tea` resolved the login, the
scripts said "no login pinned", and the error told the operator to pin what was
already pinned.
Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse
hook runs before every Bash call and must not fork a process to answer this.
The pin is a file the OPERATOR owns and `/tea:auth` writes:
<project root>/.claude/settings.local.json -> env.GITEA_LOGIN
## Search order
Start directories, in order, first hit wins:
1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set
2. an explicit hint the hook passes the Bash tool's cwd; scripts pass
nothing and go straight to 3
3. the current directory
Each start directory is searched the same way:
a. up the parent chain, from the directory itself to the filesystem root
b. then, for each LINKED WORKTREE seen on that chain, up the parent chain
of that repository's main working tree
(b) is the whole point of this module. A worktree is a *sibling* of the main
checkout, not a descendant, so `.claude/settings.local.json` — untracked, and
therefore only ever in the main checkout — is not on the parent chain of (a).
Git knows the two trees are one repository: a worktree's `.git` is a FILE
holding `gitdir: <path>`, and `<path>/commondir` points back at the shared
`.git`. `git rev-parse --git-common-dir` answers the same question by forking;
this reads the files.
## Why the search does not start at __file__
Deliberate asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which
*are* anchored on their own module's location. Two different questions:
where does this installation keep its files a fact about the plugin
whose login does this project run under a fact about the project
A plugin installed outside any repository and pointed at somebody else's tree
must answer the second one from the tree it was pointed at. Anchoring the pin
on `__file__` would make the plugin's own directory an identity source, which
is how a checkout ends up acting under a login nobody chose for it. So the
search runs from the working directory upward — and reaches a worktree's main
checkout by asking git, not by walking somewhere else.
Finding nothing is a real answer: `(None, None)` means there is no pin, and the
caller says so. This module never guesses a login.
"""
import json
import os
SETTINGS_PARTS = (".claude", "settings.local.json")
ENV_KEY = "GITEA_LOGIN"
PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR"
def settings_path(root):
"""The pin file for a project root. The one place this path is spelled."""
return os.path.join(root, *SETTINGS_PARTS)
def read_pin(path):
"""The login in a settings file, or None.
Unreadable, not JSON, no `env`, empty string — all the same answer. A
broken file is not a login and is not worth a traceback in a hook."""
try:
with open(path) as f:
value = (json.load(f).get("env") or {}).get(ENV_KEY)
except Exception:
return None
if isinstance(value, str) and value.strip():
return value.strip()
return None
def parents(start):
"""`start` and every ancestor of it, up to the filesystem root."""
d = os.path.abspath(start)
while True:
yield d
parent = os.path.dirname(d)
if parent == d:
return
d = parent
def gitdir_of(d):
"""The private git directory `d/.git` points at, or None.
Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a
directory and there is nothing to follow."""
p = os.path.join(d, ".git")
if not os.path.isfile(p):
return None
try:
with open(p) as f:
head = f.read(4096)
except OSError:
return None
for line in head.splitlines():
line = line.strip()
if line.startswith("gitdir:"):
target = line[len("gitdir:"):].strip()
if not target:
return None
if not os.path.isabs(target):
target = os.path.join(d, target)
return os.path.abspath(target)
return None
def main_worktree(d):
"""If `d` is a linked worktree, the main working tree of its repository.
`<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir`
file holds a path to `<main>/.git`; the main working tree is its parent.
The `.git` basename check keeps this to worktrees: a submodule's `.git`
is a pointer too, but it points into `<super>/.git/modules/…`, and the
tree it belongs to is already on the parent chain."""
gitdir = gitdir_of(d)
if not gitdir or not os.path.isdir(gitdir):
return None
common = gitdir
marker = os.path.join(gitdir, "commondir")
if os.path.isfile(marker):
try:
with open(marker) as f:
rel = f.read().strip()
except OSError:
rel = ""
if rel:
common = os.path.abspath(os.path.join(gitdir, rel))
if os.path.basename(common) != ".git":
return None
root = os.path.dirname(common)
if root and os.path.isdir(root) and root != os.path.abspath(d):
return root
return None
def search(start):
"""(login, path) for one start directory: the parent chain, then the main
checkout of any worktree met on it. (None, None) when there is no pin.
The chain comes first and always wins, so the worktree branch can only
ever find a pin that walking up would not have found at all."""
hops = []
for d in parents(start):
login = read_pin(settings_path(d))
if login:
return login, settings_path(d)
root = main_worktree(d)
if root and root not in hops:
hops.append(root)
for root in hops:
# One level of indirection, never two: a main checkout is not itself a
# linked worktree, so this loop cannot chain and cannot cycle.
for d in parents(root):
login = read_pin(settings_path(d))
if login:
return login, settings_path(d)
return None, None
def start_dirs(hint=None):
"""The ordered, deduplicated start directories.
`hint` is the caller's own idea of where the work is happening — the hook
passes the `cwd` from its payload, which is the directory the Bash command
will actually run in. A script has no payload and passes nothing."""
try:
cwd = os.getcwd()
except OSError: # cwd deleted out from under us
cwd = None
out = []
for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd):
if not d:
continue
d = os.path.abspath(d)
if d not in out:
out.append(d)
return out
def find_pin(hint=None):
"""(login, path) for the first start directory that has a pin, else
(None, None). The entry point; everything above is its parts."""
for start in start_dirs(hint):
login, path = search(start)
if login:
return login, path
return None, None
@@ -38,6 +38,7 @@ All offline, all in `<skill-base-dir>/scripts/`.
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors | | `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
| `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one | | `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` | | `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
| `issue_evict.py [id…] [--dry-run]` | remove closed issues from the store; **never** an `origin: local` one |
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` | | `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
| `issue.py` | the domain module the others import — not a command | | `issue.py` | the domain module the others import — not a command |
@@ -117,7 +118,9 @@ the table. Checkboxes are the exception — use `issue_ac.py`, below.
If the issue is synced (`origin: gitea`), the file is a working copy: your edit If the issue is synced (`origin: gitea`), the file is a working copy: your edit
is local until you run `push.py --update` from `/tea:sync`, and that push is local until you run `push.py --update` from `/tea:sync`, and that push
**deletes the file** once Gitea has it. Nothing tracks drift, and with one copy **deletes the file** once Gitea has it. Closing one of those is `close.py` from
`/tea:sync` — it moves the state on both sides in a single run; editing
`state:` here alone would only ever tell this machine. Nothing tracks drift, and with one copy
at a time there is little to track — a file that is still here has not been at a time there is little to track — a file that is still here has not been
pushed. Get it back with `pull.py <n>`; the slug does not change. pushed. Get it back with `pull.py <n>`; the slug does not change.
@@ -205,6 +208,50 @@ on `tmp/issues/<id>.md`, and this layer does not know the difference. Getting
the rewritten body into the tracker is a separate decision — `push.py --update` the rewritten body into the tracker is a separate decision — `push.py --update`
in `/tea:sync` — and is no part of this. in `/tea:sync` — and is no part of this.
## Evicting closed issues
The store is a working set, not an archive. A closed issue is not a unit of
work any more, and one command takes it out — no `rm`, no rebuilding `INDEX.md`
by hand:
```bash
python3 <skill-base-dir>/scripts/issue_evict.py --dry-run # what would go
python3 <skill-base-dir>/scripts/issue_evict.py # every closed one
python3 <skill-base-dir>/scripts/issue_evict.py old-thing # just this one
```
Two conditions, both read off the file, and the second one is the whole safety
argument:
| `state:` | `origin:` | what eviction does |
|---|---|---|
| `closed` | a tracker | removes `<id>.md` and every sidecar under that slug |
| `closed` | `local` | **keeps it, always**, and says why |
| `open` | anything | keeps it |
**`origin: local` is never evicted, in any state, not even when you name it on
the command line.** That file *is* the issue; there is no copy to fetch back.
Only a file whose own metadata says the work lives somewhere else may go — the
same trade `push.py` makes when it drops a file the tracker just confirmed.
- `--dry-run` prints what would go and writes nothing at all, `INDEX.md`
included.
- `INDEX.md` is rebuilt afterwards, so the table and the directory agree. It is
rebuilt only when something was actually removed.
- `.remote.json` is **not** pruned, deliberately: it is the number → slug
ledger, and its entries are supposed to outlive the files they name (that is
what makes `pull.py <n>` land on the same slug after a push). An evicted issue
is in exactly the state a pushed one is.
- **This is not a one-off migration.** `pull.py <n>` fetches an issue in any
state — a number is an address, not a query — so a closed issue pulled after
an eviction lands on disk again. Not a regression: evict it again when you are
done reading it.
This command is offline and decides from `state:` in the file, which is only as
fresh as the last pull. To have the tracker's answer instead — an issue closed
in the web UI five minutes ago — use `/tea:sync`'s `evict.py`, which refreshes
`state:` first and then calls exactly this decision.
## Dependency graph ## Dependency graph
`depends:` is the authoritative edge list; the body's `## Depends on` section `depends:` is the authoritative edge list; the body's `## Depends on` section
@@ -3,8 +3,7 @@
Canonical format for every issue in this project, whether it ever reaches a Canonical format for every issue in this project, whether it ever reaches a
tracker or not. Designed to be unambiguous for both humans and LLMs: fixed tracker or not. Designed to be unambiguous for both humans and LLMs: fixed
English section headers in a fixed order, verifiable acceptance criteria, one English section headers in a fixed order, verifiable acceptance criteria, one
issue = one deliverable. Source spec: the project wiki issue = one deliverable.
([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
Nothing here depends on Gitea. How these files are mapped onto a tracker is the Nothing here depends on Gitea. How these files are mapped onto a tracker is the
sync layer's business — see `/tea:sync`. sync layer's business — see `/tea:sync`.
@@ -42,7 +41,6 @@ labels: [type/task, tech/sql]
assignees: [naudachu] assignees: [naudachu]
milestone: v0.2 milestone: v0.2
depends: [migrate-schema] depends: [migrate-schema]
wiki: [Simple Chains/Ideas/Chain core]
origin: gitea origin: gitea
branch: feat/wire-sqlc branch: feat/wire-sqlc
gitea: claude-skills/tea#42 gitea: claude-skills/tea#42
@@ -64,7 +62,6 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
| `assignees` | domain | logins; may be empty | | `assignees` | domain | logins; may be empty |
| `milestone` | domain | title, or `none` | | `milestone` | domain | title, or `none` |
| `depends` | domain | ids this issue depends on — **the authoritative graph** | | `depends` | domain | ids this issue depends on — **the authoritative graph** |
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them |
| `origin` | domain | `local`, or the name of a tracker this also lives in | | `origin` | domain | `local`, or the name of a tracker this also lives in |
| `gitea` | sync | the handle in that tracker: `owner/repo#N` | | `gitea` | sync | the handle in that tracker: `owner/repo#N` |
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one | | `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
@@ -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 leaves this machine is valid and finished work; pushing it is optional and
nothing here treats it as a draft. nothing here treats it as a draft.
It is not a *permanent* state, and this is the one place where the file's fate It is not a *permanent* state, and it is what the file's fate depends on:
depends on it:
| `origin:` | what the file is | what a push does to it | | `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 | | `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 | | 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 **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 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 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. 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 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. 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, # Domain-owned metadata, in render order. Foreign keys render after these,
# sorted, so the sync layer can add fields without touching this list. # sorted, so the sync layer can add fields without touching this list.
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"] DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
"origin"]
LIST_KEYS = {"labels", "assignees", "depends"} LIST_KEYS = {"labels", "assignees", "depends"}
STATES = ("open", "closed") STATES = ("open", "closed")
@@ -247,8 +248,8 @@ class Issue(object):
"""One unit of work. `extra` holds metadata this layer does not own.""" """One unit of work. `extra` holds metadata this layer does not own."""
def __init__(self, id="", title="", body="", state="open", labels=None, def __init__(self, id="", title="", body="", state="open", labels=None,
assignees=None, milestone="", depends=None, origin=LOCAL, assignees=None, milestone="", depends=None,
extra=None): origin=LOCAL, extra=None):
self.id = id self.id = id
self.title = title self.title = title
self.body = body self.body = body
@@ -635,6 +636,35 @@ def all_ids(root):
and "." not in f[:-3]) and "." not in f[:-3])
def slug_files(root, id):
"""Every file the store holds under one slug — the issue and its sidecars.
`<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
companion another layer parked there (`<id>.comments.md` is the one that
exists today). `all_ids` already refuses to read those as issues because a
slug has no dot in it; this is the same rule read the other way round.
Which is how the domain can remove an issue *completely* without learning
what any of those companions are: it does not need to know that a comment
thread exists to know that a file named after this issue belongs to it and
goes when it goes. The issue's own file comes first — it is the headline of
any receipt printed from this list.
A missing store is an empty list, not an error: nothing is there to remove.
"""
if not os.path.isdir(root):
return []
own, sidecars = [], []
for name in sorted(os.listdir(root)):
if not name.startswith("%s." % id):
continue
p = os.path.join(root, name)
if not os.path.isfile(p):
continue
(own if name == "%s.md" % id else sidecars).append(p)
return own + sidecars
def load(root, id): def load(root, id):
with open(path_of(root, id)) as f: with open(path_of(root, id)) as f:
return Issue.from_text(f.read(), id=id) 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,6 +1,6 @@
--- ---
name: sync name: sync
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, or comment on one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network. description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
--- ---
# /tea:sync — the bridge between the local store and Gitea # /tea:sync — the bridge between the local store and Gitea
@@ -32,15 +32,22 @@ index.
## Scripts ## Scripts
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
operator's pin from `.claude/settings.local.json` themselves, the same source operator's pin from `.claude/settings.local.json` through
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`. `skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
not merely the same file, so a directory where `tea` works is a directory where
these work. That includes a **git worktree**, whose untracked pin sits in the
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
`/tea:auth`.
| Script | What it does | | Script | What it does |
|---|---| |---|---|
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing | | `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty | | `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now | | `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread | | `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` | | `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
| `map.py`, `_gitea.py` | the two layers the commands import — not commands | | `map.py`, `_gitea.py` | the two layers the commands import — not commands |
@@ -88,7 +95,7 @@ python3 <skill-base-dir>/scripts/pull.py 42
python3 <skill-base-dir>/scripts/pull.py --milestone 6 # id or title python3 <skill-base-dir>/scripts/pull.py --milestone 6 # id or title
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20 python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20
python3 <skill-base-dir>/scripts/pull.py 40 --deps # follow dependencies python3 <skill-base-dir>/scripts/pull.py 40 --no-deps # this issue only
``` ```
Do not loop over numbers to pull a group — pass the filter. The list endpoint Do not loop over numbers to pull a group — pass the filter. The list endpoint
@@ -114,13 +121,55 @@ already on disk is refreshed either way — the local copy learns it was closed
instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a
closed issue as always, because an address is not a bulk read. closed issue as always, because an address is not a bulk read.
**`--limit N` bounds the write, not the selection.** N is how many issues this
run leaves in the store — written, or left in place by `--cached`. Closed ones
that were enumerated and thrown away do not spend it, so `--limit 20` over a
milestone whose first 30 issues are closed still writes 20, as long as 20 open
ones are there to write. Pagination follows the budget rather than the other way
round:
| | |
|---|---|
| budget full | the next page is never requested |
| pages run out | fewer than N, and that is the honest answer |
| filter matches almost only closed issues | at most 4× the pages N would need if nothing were dropped, then a warning on stderr and a short answer — raising `--limit` raises that ceiling too |
| dependencies | outside the count: a blocker is followed because a stored issue named it, not because the filter selected it — so `--limit 20` can leave more than 20 files behind |
`remote.py --limit` means something else, deliberately: it caps the **listing**,
closed issues included. It writes nothing, so there is no write for a limit to
bound — enumeration is its whole job.
**Comments come with every pull** — there is no flag. An issue that has a **Comments come with every pull** — there is no flag. An issue that has a
thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter
mode alike, and the issue's output line says how many. An issue with none mode alike, and the issue's output line says how many. An issue with none
costs nothing: the count arrives in the list payload, so no request is made costs nothing: the count arrives in the list payload, so no request is made
and no file is written — and a file left over from a thread that has since and no file is written — and a file left over from a thread that has since
been emptied is deleted. `--cached` skips the thread along with the body, so a been emptied is deleted. `--cached` skips the thread along with the body, so a
skipped issue makes no request at all. skipped issue makes one request for its links and no other.
**Dependencies come with every pull too, and this one costs.** A pull answers
with the unit of work — the issue and what blocks it — so `depends:` is filled
from Gitea's native graph and every blocker is pulled as well, recursively, down
to `--depth` (default 3). It has to come from the native graph: the body's
`## Depends on` section holds slugs, never `#N`, so there is no edge to recover
from the text. `--no-deps` turns off both halves. `--deps` is still accepted and
does nothing — it names the default.
| | requests |
|---|---|
| every issue that lands in the store | **+1** — `GET …/issues/{n}/dependencies`, fetched once and used twice (fills `depends:`, steers the walk) |
| every blocker the selection did not already carry | **+1** to fetch it, then its own links, until `--depth` |
| a closed issue filter mode drops | 0 — nothing was stored, so there is no unit of work to complete |
| `--milestone X` over 50 open issues | 1 list request + 50, plus a pair per outside blocker — it used to be 1 |
| the same with `--no-deps` | 1 |
**A blocker the filter did not select still lands in the store, deliberately.**
`--milestone X` can leave an issue from milestone Y on disk; `--label` can leave
an unlabelled one. It is there because a stored issue names it, not because it
matched. The exception is a closed blocker: closed is not a unit of work, filter
mode drops it like any other closed issue, and the `depends:` edge to it goes
with it — nothing is left pointing at a file that is not there. Key mode
(`pull.py 42`) has no such rule and stores it.
Two traps this handles for you: Two traps this handles for you:
@@ -233,7 +282,7 @@ accumulate them however many round trips it makes, and why a body that somehow
gained two is cleaned on the next pull. gained two is cleaned on the next pull.
`depends:` survives the same round trip through Gitea's native links (below): `depends:` survives the same round trip through Gitea's native links (below):
push writes them, `pull.py --deps` reads them back, and the ledger turns the push writes them, every `pull.py` reads them back, and the ledger turns the
numbers into the slugs they had here. numbers into the slugs they had here.
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`, Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
@@ -253,7 +302,7 @@ The two directions are symmetric, and they use the same endpoint:
| | direction | endpoint | | | direction | endpoint |
|---|---|---| |---|---|---|
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` | | `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
| `pull.py --deps` | native links → `depends:` | `GET …/issues/{n}/dependencies` | | `pull.py` (default; `--no-deps` off) | native links → `depends:` | `GET …/issues/{n}/dependencies` |
The POST body is Gitea's `IssueMeta``{"index", "owner", "repo"}` naming the The POST body is Gitea's `IssueMeta``{"index", "owner", "repo"}` naming the
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the **blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
@@ -298,16 +347,128 @@ decision, not a migration. A color or `exclusive` that drifted is printed, and
changed only under `--fix`. Running it twice creates nothing. `tech/*` and changed only under `--fix`. Running it twice creates nothing. `tech/*` and
`comp/*` are open-ended by design and stay push-created. `comp/*` are open-ended by design and stay push-created.
Labels belong to the repository, not to any issue, so this one runs on a
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
and nothing creates it. The request bodies go to `tmp/payload/` (below).
A milestone must already exist in the repo — push attaches, it does not create. A milestone must already exist in the repo — push attaches, it does not create.
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills `branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`) an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
and writes it back into the issue file; a value already there is never and sends it up as `ref`; a value already there is never
overwritten, neither on create nor on `--update`. On a detached HEAD or outside overwritten, neither on create nor on `--update`. Nothing is written back to
the issue file — there is no file left to write to, because a successful push
deletes it. The branch comes back on disk with the next `pull.py <n>`, from
the tracker. On a detached HEAD or outside
a git repo no `ref` is sent and a warning names the issues that went up without a git repo no `ref` is sent and a warning names the issues that went up without
one. Reading the branch is the only thing these scripts ask git for — they one. Reading the branch is the only thing these scripts ask git for — they
never check out, create, or write anything. never check out, create, or write anything.
The branch comes from the **current directory**, so run `push.py` from the tree
the work is on. In a git worktree that is the worktree, and it is now also
where the pin resolves from: the old workaround for the pin — run the scripts
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
is the one thing `branch:` exists to record.
## Closing and reopening
```bash
python3 <skill-base-dir>/scripts/close.py wire-sqlc-appclick # by slug
python3 <skill-base-dir>/scripts/close.py 42 '#43' # by number
python3 <skill-base-dir>/scripts/close.py --reopen 42
python3 <skill-base-dir>/scripts/close.py --dry-run 42 43 # no request at all
```
`close.py` is the only supported way to move `state:`. Never hand-roll
`tea api -X PATCH -d '{"state":"closed"}' repos/OWNER/REPO/issues/N`: it spells
out the owner, the repo and the request body — the three things this layer
exists to hide — and it needs a `Bash(tea api *)` permission that also covers
`-X DELETE` on the repository.
**State only.** The payload is `{"state": …}` and nothing else — no title, no
body, no labels, no milestone. Closing is not an edit; editing is `pull.py`
change → `push.py --update`.
**Explicit ids only.** There is no `--milestone` and no `--label`: which issues
are finished is a judgement about content, and this script only carries one
out, one named id at a time. Deleting an issue is out of scope too — Gitea can,
and it is not an operation of this workflow.
What may be named, and what happens to the local copy:
| named | resolved through | local file |
|---|---|---|
| a slug with a file on disk | its `gitea:` field | `state:` rewritten, `synced:` refreshed |
| a slug whose file push dropped | `.remote.json` | none to write — say so and move on |
| `42`, `#42`, `owner/repo#42`, a URL | the key itself; the ledger supplies the slug | rewritten when a file of that slug is there |
| a slug with `origin: local` | — | **refused**: it is not in the tracker, and the error names the id |
The local file is written only after the tracker has confirmed *this* write: an
object carrying the very number that was PATCHed, in the state that was asked
for. A non-2xx, a `tea` that would not run, an answer for another issue, a 200
that still says `open` — the run stops and the file is byte for byte what it
was. `--dry-run` prints the same lines and makes no request at all, so it needs
no pinned login.
Gitea refuses to close an issue that its own dependency graph still blocks. The
refusal arrives as a non-2xx with the tracker's own words: close the blockers
first, or unlink them in the web UI.
The index is rebuilt when at least one local file changed, so `INDEX.md` never
outlives the state it reports. Nothing is deleted here — unlike a push, a close
leaves the working copy where it is.
## Evicting what the tracker says is closed
```bash
python3 <skill-base-dir>/scripts/evict.py --dry-run # ask, report, change nothing
python3 <skill-base-dir>/scripts/evict.py # and remove them
python3 <skill-base-dir>/scripts/evict.py old-thing # just this one
```
Eviction itself belongs to `/tea:issue` (`issue_evict.py`) and is offline: the
decision is `state: closed` plus an `origin:` that names a tracker, both read
off the file. This script adds one thing in front of it — a `state:` that is not
stale — and then calls that same decision. There is one implementation of "what
may be evicted" and it is in the domain.
Why it exists: a local `state:` is only as fresh as the last pull, so an issue
closed in the web UI still reads `open` here and the offline command correctly
leaves it alone. The workaround was `pull.py 11 12 13 14 15` — which writes the
five closed files back to disk before anything can remove them.
Order of operations, and it is the safety argument:
1. every candidate's state is fetched — **all** of them, before anything is
removed;
2. each answer must be an object carrying the number that was asked about and a
state the domain recognizes (`evict.confirmed_state`, the counterpart of
`push.confirmed_number`);
3. only then does the eviction run.
**A failed call evicts nothing** — not even the candidates whose answers had
already arrived, and no refreshed `state:` is written back either. Stricter than
push, which deletes as it goes, and free: evictions have no order between them,
so there is no reason to start before every answer is in.
- A **candidate** is an issue carrying a `gitea:` handle. `origin: local` has
none, is never asked about, and is never removed. An `origin: gitea` issue
whose handle is missing or unparseable cannot be verified — it is reported on
stderr and kept.
- No `--repo`: the repo comes from each issue's own handle, so a store holding
issues from two repos is checked against both.
- One GET per candidate. The store is a working set that push keeps small, and a
wrong answer here deletes a file — so each issue is asked about by its own
address rather than inferred from a list a `--limit` could have truncated.
- A state that disagrees with the file is written back, so the store stops lying
about the issues that stay too. `--dry-run` makes no writes at all.
- `.remote.json` is not pruned; see [How the slug comes
back](#how-the-slug-comes-back) — an evicted issue is exactly as findable as a
pushed one.
- **`pull.py <n>` still fetches a closed issue.** A number is an address, not a
query. A closed issue pulled after an eviction is back on disk, and that is
the tracker answering the question it was asked, not a regression.
## What crosses the boundary, and what does not ## What crosses the boundary, and what does not
| domain | Gitea | note | | domain | Gitea | note |
@@ -319,7 +480,7 @@ never check out, create, or write anything.
| `labels` | `labels[]` | names both ways; ids only on write | | `labels` | `labels[]` | names both ways; ids only on write |
| `assignees` | `assignees[]` | logins | | `assignees` | `assignees[]` | logins |
| `milestone` | `milestone.title` | resolved to an id on write | | `milestone` | `milestone.title` | resolved to an id on write |
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, `pull --deps` reads them | | `depends` | native links | slugs here, `IssueMeta` there; push writes them, every pull reads them (`--no-deps` opts out) |
| — | `ref` | lands in `branch:`; sent only when non-empty | | — | `ref` | lands in `branch:`; sent only when non-empty |
| — | `number`, `html_url` | lands in `gitea:` / `url:` | | — | `number`, `html_url` | lands in `gitea:` / `url:` |
@@ -354,6 +515,13 @@ precisely so the mechanism this section rules out is not needed.
## Rich payloads for everything else ## Rich payloads for everything else
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
first and passed as `-d @file`, then kept for a retry or a look at what actually
went up. One gitignored directory for all of them, chosen by the transport and
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
and it is never `tmp/issues/` — a command that touches no issue must not leave
an issue store behind.
Comments and issues are wrapped by the scripts above. For **other** entities Comments and issues are wrapped by the scripts above. For **other** entities
(pulls, releases, PATCHing something these scripts do not cover), entity (pulls, releases, PATCHing something these scripts do not cover), entity
subcommands like `tea pulls create` hang on a large or formatted body — an subcommands like `tea pulls create` hang on a large or formatted body — an
@@ -7,8 +7,10 @@ query quirks. It does NOT know what an issue is: no sections, no acceptance
criteria, no type taxonomy. Payload shapes come from map.py; the domain model criteria, no type taxonomy. Payload shapes come from map.py; the domain model
lives one layer further out in skills/issue/scripts/issue.py. lives one layer further out in skills/issue/scripts/issue.py.
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
from CWD the same file /tea:auth writes and the tea-guard hook reads. No Where that file is searched for is NOT written here skills/auth/scripts/pin.py
owns the search order, and the tea-guard hook imports the same module, so `tea`
and the scripts can never disagree about which login a directory runs under. No
script here accepts a login argument: the operator's pin is the only identity script here accepts a login argument: the operator's pin is the only identity
they will use. No pin -> exit with a pointer to /tea:auth. they will use. No pin -> exit with a pointer to /tea:auth.
@@ -18,6 +20,13 @@ it is transport bookkeeping, not domain data — the domain never reads any of
it, and losing the map still costs a re-pull and not information: the slug it it, and losing the map still costs a re-pull and not information: the slug it
records also travels in the issue body as `<!-- tea:id -->` (map.py), so a records also travels in the issue body as `<!-- tea:id -->` (map.py), so a
pull rebuilds the entry from the tracker. See `rebuild_map`. pull rebuilds the entry from the tracker. See `rebuild_map`.
Request bodies go to tmp/payload/, which is this module's own scratchpad and
NOT a store: nothing in it is anybody's only copy, and writing one must never
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
touches no issue at all it used to leave a store behind anyway, because the
request file had nowhere else to live. One directory, every caller, resolved
from this file the way the two domains resolve theirs.
""" """
import datetime import datetime
import json import json
@@ -27,9 +36,32 @@ import re
import sys import sys
import urllib.parse import urllib.parse
PAYLOAD_DIR = ".payload"
REMOTE_MAP = ".remote.json" REMOTE_MAP = ".remote.json"
# How far past the ideal page count a `keep`-bounded listing may scan before it
# gives up (see list_issues). The ideal is what `limit` would need if every
# payload counted; the slack pays for the ones that do not. It is a bound on
# requests, deliberately small: "fetch until N are kept" without one is "fetch
# the whole tracker" on any repo whose filter matches mostly closed issues.
PAGE_SLACK = 4
# --------------------------------------------------------------------------
# where request bodies land
# --------------------------------------------------------------------------
# Anchored on THIS FILE, like issue.store_root, so every caller — sync,
# whatever comes next — writes to one directory whatever it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
# inside somebody's store, because a scratchpad that looks like store contents
# is how this went wrong the first time. `tmp/` is already gitignored.
PAYLOAD_PARTS = ("tmp", "payload")
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
# git; the agents-sync hook only ever puts one at a repository root.
REPO_MARKERS = (".git", "AGENTS.md")
_HERE = os.path.dirname(os.path.abspath(__file__))
def die(msg, code=1): def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg)) sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
@@ -44,32 +76,64 @@ def now_iso():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# -------------------------------------------------------------------------- def repo_root(start):
# login """Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
# -------------------------------------------------------------------------- d = os.path.abspath(start)
def find_pin(start_dir=None):
"""Walk up from start_dir; return the login from the first
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
d = os.path.abspath(start_dir or ".")
while True: while True:
p = os.path.join(d, ".claude", "settings.local.json") if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
if os.path.isfile(p): return d
try:
with open(p) as f:
v = (json.load(f).get("env") or {}).get("GITEA_LOGIN")
if isinstance(v, str) and v.strip():
return v.strip()
except Exception:
pass
parent = os.path.dirname(d) parent = os.path.dirname(d)
if parent == d: if parent == d:
return None return None
d = parent d = parent
def payload_root(start=None):
"""Absolute path of the request-body scratchpad.
`start` overrides the anchor so the resolution can be exercised against a
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
location stands made absolute so an error can name the directory it
really wrote to."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *PAYLOAD_PARTS)
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
PAYLOAD_ROOT = payload_root()
# --------------------------------------------------------------------------
# login
# --------------------------------------------------------------------------
# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the
# single written copy of the search order, and the tea-guard hook calls the
# same function. When the two had a copy each, a git worktree got a hook that
# resolved the pin and a transport that did not — in the same directory.
#
# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those
# are anchored on their own file, this is not, and both are right. Where an
# installation keeps its files is a fact about the installation; whose login a
# project runs under is a fact about the project, and a plugin installed
# outside any repository must not answer it from its own directory. See the
# module docstring in pin.py.
_AUTH_SCRIPTS = os.path.abspath(
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
if _AUTH_SCRIPTS not in sys.path:
sys.path.append(_AUTH_SCRIPTS)
import pin # noqa: E402
def require_login(): def require_login():
login = find_pin(os.getcwd()) """The operator's pinned login, or exit pointing at /tea:auth.
No pin found is reported as exactly that. It stays a truthful message: the
fix for "the pin is somewhere this search does not reach" belongs in
pin.py, never in a hint here that sends the operator to pin it twice."""
login, _ = pin.find_pin()
if not login: if not login:
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.") die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
return login return login
@@ -80,19 +144,21 @@ def require_login():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def api(login, endpoint, method="GET", payload=None, payload_name=None, def api(login, endpoint, method="GET", payload=None, payload_name=None,
out_root=None, allow_fail=False): allow_fail=False):
"""Call `tea api`; return parsed JSON (None on an empty body). """Call `tea api`; return parsed JSON (None on an empty body).
payload (a dict) is written to <out_root>/.payload/<name>.json and passed payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
as -d @file the file survives the call for retries and debugging. -d @file the file survives the call for retries and debugging. Where
allow_fail returns None instead of exiting when the call fails.""" that is, is not the caller's business and never was: the directory is
this layer's scratchpad, and the one time it was a caller's decision it
got pointed at the issue store. allow_fail returns None instead of
exiting when the call fails."""
cmd = ["tea", "api", "--login", login] cmd = ["tea", "api", "--login", login]
if method != "GET": if method != "GET":
cmd += ["-X", method] cmd += ["-X", method]
if payload is not None: if payload is not None:
pdir = os.path.join(out_root or ".", PAYLOAD_DIR) os.makedirs(PAYLOAD_ROOT, exist_ok=True)
os.makedirs(pdir, exist_ok=True) path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
path = os.path.join(pdir, "%s.json" % (payload_name or "request"))
with open(path, "w") as f: with open(path, "w") as f:
json.dump(payload, f, ensure_ascii=False, indent=2) json.dump(payload, f, ensure_ascii=False, indent=2)
cmd += ["-d", "@" + path] cmd += ["-d", "@" + path]
@@ -114,17 +180,28 @@ def api(login, endpoint, method="GET", payload=None, payload_name=None,
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500])) die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
def paginate(login, endpoint, limit=50, max_pages=40, **kw): def pages(login, endpoint, limit=50, max_pages=40, **kw):
"""GET a list endpoint page by page; return the concatenated list.""" """GET a list endpoint page by page, yielding each page as it arrives.
A generator, because a caller whose budget is spent on what it *keeps*
cannot be served by a function that fetches everything first: the page after
the one that completed the budget must never be requested. Stop consuming
and no further request is made."""
sep = "&" if "?" in endpoint else "?" sep = "&" if "?" in endpoint else "?"
out = []
for page in range(1, max_pages + 1): for page in range(1, max_pages + 1):
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw) batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
if not isinstance(batch, list) or not batch: if not isinstance(batch, list) or not batch:
break return
out.extend(batch) yield batch
if len(batch) < limit: if len(batch) < limit:
break return # a short page is the last one
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
"""GET a list endpoint page by page; return the concatenated list."""
out = []
for batch in pages(login, endpoint, limit=limit, max_pages=max_pages, **kw):
out.extend(batch)
return out return out
@@ -187,11 +264,31 @@ def matches(payload, milestone_id=None, labels=()):
def list_issues(login, base, state="open", labels=(), query=None, def list_issues(login, base, state="open", labels=(), query=None,
milestone=None, limit=100): milestone=None, limit=100, keep=None):
"""Filtered issue payloads. Returns (payloads, milestone_title). """Filtered issue payloads. Returns (payloads, milestone_title).
One request per page, and the payload already carries the issue bodies a One request per page, and the payload already carries the issue bodies a
whole milestone costs one call per 50 issues, not one per issue.""" whole milestone costs one call per 50 issues, not one per issue.
`limit` counts the payloads the CALLER cares about, not the ones the server
returned. Without `keep` those are the same thing and this behaves as it
always did. With it, `keep(payload)` says whether a payload counts, pages
keep coming until `limit` of them have, and the returned list carries the
ones that did not count too they were enumerated, and a caller that has
something to say about them (pull.py: "N closed, not stored") still can.
What `keep` means is the caller's business; this module only counts. Two
boundaries hold whatever it decides:
- **Stop at the limit.** The page after the one that completed the budget
is not requested `pages` is a generator and this loop returns out of it.
- **Stop at the page budget.** A predicate that rejects everything must not
turn a bounded read into a walk of the whole tracker, so a filtered read
may scan at most `PAGE_SLACK` times the pages `limit` would need if every
payload counted. Hitting that with an unfilled budget is a warning, not a
silent short answer: the caller asked for N and is told it got fewer."""
if limit < 1:
die("--limit must be 1 or more, got %d" % limit)
ms_id, ms_title = (None, None) ms_id, ms_title = (None, None)
if milestone is not None: if milestone is not None:
ms_id, ms_title = resolve_milestone(login, base, milestone) ms_id, ms_title = resolve_milestone(login, base, milestone)
@@ -206,10 +303,25 @@ def list_issues(login, base, state="open", labels=(), query=None,
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params)) endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
per_page = min(limit, 50) per_page = min(limit, 50)
got = paginate(login, endpoint, limit=per_page, ideal = max(1, -(-limit // per_page))
max_pages=max(1, -(-limit // per_page))) budget = ideal if keep is None else ideal * PAGE_SLACK
got = [p for p in got if matches(p, ms_id, labels)]
return got[:limit], ms_title got, kept, seen_pages, last_full = [], 0, 0, False
for batch in pages(login, endpoint, limit=per_page, max_pages=budget):
seen_pages += 1
last_full = len(batch) == per_page
for p in batch:
if not matches(p, ms_id, labels):
continue
got.append(p)
if keep is None or keep(p):
kept += 1
if kept >= limit:
return got, ms_title
if keep is not None and seen_pages >= budget and last_full:
warn("scanned %d page(s) and stopped %d short of --limit %d — there may"
" be more; narrow the filter or raise --limit" % (budget, limit - kept, limit))
return got, ms_title
def get_issue(login, base, number): def get_issue(login, base, number):
@@ -245,7 +357,7 @@ def native_dep_pairs(login, base, number):
return out return out
def add_dependency(login, base, number, dep_repo, dep_number, out_root=None): def add_dependency(login, base, number, dep_repo, dep_number):
"""Make issue `number` depend on `dep_repo#dep_number`. True on success. """Make issue `number` depend on `dep_repo#dep_number`. True on success.
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1): Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
@@ -264,8 +376,7 @@ def add_dependency(login, base, number, dep_repo, dep_number, out_root=None):
return False return False
payload = {"index": int(dep_number), "owner": owner, "repo": name} payload = {"index": int(dep_number), "owner": owner, "repo": name}
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload, got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
payload_name="dep-%d-%d" % (number, dep_number), payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
out_root=out_root, allow_fail=True)
return got is not None return got is not None
@@ -297,7 +408,7 @@ def ensure_labels(login, base, specs, root):
continue continue
payload = dict(spec, name=name) payload = dict(spec, name=name)
created = api(login, "%s/labels" % base, "POST", payload, created = api(login, "%s/labels" % base, "POST", payload,
payload_name="label-%s" % name.replace("/", "-"), out_root=root) payload_name="label-%s" % name.replace("/", "-"))
if not created or "id" not in created: if not created or "id" not in created:
die("could not create label %r" % name) die("could not create label %r" % name)
cache[name] = created["id"] cache[name] = created["id"]
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""
close.py — change an issue's state in Gitea, and in the local copy with it.
The one regular tracker operation that used to have no script: closing. Without
it the only way to move `state:` was a raw `tea api -X PATCH -d '{"state":
"closed"}' repos/OWNER/REPO/issues/N`, which spells out the owner, the repo and
the request body — the three things `_gitea.py` exists to hide — and which needs
`Bash(tea api *)`, a permission that also covers `-X DELETE` on the repository.
close.py wire-sqlc-appclick one issue, by slug
close.py wire-sqlc-appclick 42 #43 several, by slug or number
close.py --reopen 42 the same thing backwards
close.py --dry-run 42 43 what would happen, no request at all
STATE ONLY. This script sends `{"state": …}` and nothing else: no title, no
body, no labels, no milestone. Editing an issue is `pull.py` -> edit ->
`push.py --update`; closing it is not an edit.
**What may be named.** A local slug, or a Gitea key (`42`, `#42`,
`owner/repo#42`, an issue URL) — the same forms `pull.py` takes. Both are
needed, and for the same reason: a push deletes the local file, so most issues
in the tracker have no slug on disk to name them by. A slug is resolved through
the file's `gitea:` field when the file is there, and through the ledger
(`.remote.json`) when push has already dropped it.
**An `origin: local` issue cannot be closed.** It is not in the tracker, so
there is nothing to close there, and the run stops naming the id rather than
quietly editing one field of a local file. Delete it, or push it first.
**Explicit ids only.** No `--milestone`, no `--label`, no "close everything
that looks done". Which issues are finished is a judgement about content; this
script only carries it out, one named id at a time. Nothing here deletes an
issue either — Gitea can, and it is not an operation of this workflow.
The local file is written only after the tracker has confirmed the write:
1. `tea` ran and exited 0 (a non-2xx exits the run inside `_gitea.api`), and
2. the answer is an object carrying the very number that was PATCHed, and
3. its `state` is the state we asked for.
Anything else and the file is left exactly as it was — see `confirmed`. An
issue whose local copy is gone (pushed and dropped) is closed in Gitea and
nothing is written; the state comes down with the next `pull.py`.
Gitea refuses to close an issue that its own dependency graph still blocks. That
refusal arrives as a non-2xx and stops the run with the tracker's own words:
close the blockers first, or unlink them in the web UI.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import re
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import issue_index # noqa: E402
import map as gmap # noqa: E402
# What `_gitea.parse_key` accepts, asked as a question instead of an assertion:
# parse_key exits on anything it cannot read, and here "not a key" is the normal
# case — it means the argument is a slug. A slug never contains `#`, `/` or `:`,
# so the two vocabularies cannot collide.
KEY_RE = re.compile(r'^(#?\d+|[\w.-]+/[\w.-]+#\d+|https?://\S+)$')
def looks_like_key(arg):
return bool(KEY_RE.match((arg or "").strip()))
def ledger_pairs(remote_map, repo=None):
"""[(repo, number, slug)] from `.remote.json`, filtered to `repo`.
A `--repo` that was not given means "whatever the ledger holds": resolving
the repo's real name costs a request, and a dry run is required to make
none. The ambiguity that opens — one number under two repos — is caught at
lookup time rather than papered over."""
out = []
for key, slug in sorted(remote_map.items()):
r, n = gmap.parse_remote_key(key)
if n:
if repo is None or r == repo:
out.append((r, n, slug))
return out
def one(candidates, what, arg):
"""The single `(repo, value)` in `candidates`, None when empty, or exit.
Two answers mean the ledger knows this number (or this slug) under more than
one repository, and only `--repo` can settle that."""
got = sorted(set(candidates))
if len(got) > 1:
_gitea.die("%r matches %s under more than one repo (%s) — pass "
"--repo owner/repo" % (arg, what, ", ".join(r for r, _v in got)))
return got[0] if got else None
def resolve(arg, issues, pairs):
"""(id, number, repo) for one argument. Either of `id` and `repo` is None
when nothing this machine holds names it.
Order, and it is the order of what is most authoritative about this machine:
a file on disk, then the ledger, then nothing. A key skips straight to the
ledger — its number is already the tracker's answer, and the slug is only
wanted so the local copy, if there is one, can be kept honest.
`repo` travels out with the number because a key may name one
(`owner/repo#42`) and a `gitea:` field always does. Sending a foreign key to
whatever repo the CWD happens to be in would close somebody else's issue of
the same number, so the caller reconciles them before anything goes out."""
if looks_like_key(arg):
number, repo = _gitea.parse_key(arg)
hit = one([(r, s) for r, n, s in pairs
if n == number and (repo is None or r == repo)], "a slug", arg)
return (hit[1] if hit else None), number, repo or (hit[0] if hit else None)
iss = issues.get(arg)
if iss is not None:
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
if not number:
_gitea.die("%s is not in the tracker (origin: %s, no gitea: field) — "
"there is no state there to change; push.py %s first"
% (arg, iss.origin, arg))
return arg, number, repo
hit = one([(r, n) for r, n, s in pairs if s == arg], "a number", arg)
if hit:
return arg, hit[1], hit[0] # pushed, and its file went with the push
_gitea.die("no issue %r in the store or the ledger — pass a Gitea number "
"(42, #42, owner/repo#42, a URL) to close one this machine has "
"never seen" % arg)
def confirmed(got, number, state):
"""True when the tracker's answer confirms THIS write, and nothing else.
The gate in front of the local write, and deliberately boring: an answer
counts only when it is an object carrying the very number that was PATCHed
(`bool` rejected explicitly — `True` is an `int`) and the state that was
asked for. A non-2xx and a `tea` that would not run never reach here at all;
`_gitea.api` exits on both, so the file survives those by never being
written."""
if not isinstance(got, dict):
return False
n = got.get("number")
if isinstance(n, bool) or not isinstance(n, int) or n != number:
return False
return got.get("state") == state
def apply_state(root, iss, state, got):
"""Write the confirmed state onto the local file; return its path.
`state:` is the domain's own field, so it is set on the issue and written
out by the domain's own writer. The sync-owned freshness fields travel with
it: the answer that authorized this write is also the newest thing the
tracker has said about the issue, so `synced:` and `remote-updated:` are
stamped from it rather than left describing an older read."""
iss.state = state
iss.extra["synced"] = _gitea.now_iso()
if got.get("updated_at"):
iss.extra["remote-updated"] = got["updated_at"]
return issue.save(root, iss)
def main():
ap = argparse.ArgumentParser(description="Close (or reopen) issues in Gitea")
ap.add_argument("ids", nargs="+",
help="local ids, or Gitea keys: 42, #42, owner/repo#42, URL")
ap.add_argument("--reopen", action="store_true",
help="set the state back to open instead of closed")
ap.add_argument("--dry-run", action="store_true",
help="print what would change; makes no request at all")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args()
root = args.out
state = "open" if args.reopen else "closed"
verb = "reopen" if args.reopen else "close"
past = "reopened" if args.reopen else "closed"
# A store that is not there is not an error here: a number needs no local
# file, and closing an issue whose copy was dropped by push is the normal
# case. `load_all` reads an absent directory as an empty one.
issues = issue.load_all(root)
pairs = ledger_pairs(_gitea.load_map(root), args.repo)
# Every argument is resolved before anything is sent, so a typo in the third
# id does not leave the first two closed.
targets = []
for arg in args.ids:
got = resolve(arg, issues, pairs)
if got not in targets:
targets.append(got)
# One run, one repo. An explicit --repo is the operator's word and wins;
# without one, the repo comes from what the ids themselves said, and two
# answers are a question rather than a guess — `repo_base` would otherwise
# let `tea` fill the blank from the CWD and close the wrong #42.
named = {r for _i, _n, r in targets if r}
if not args.repo and len(named) > 1:
_gitea.die("all ids must belong to one repo, got: %s" % ", ".join(sorted(named)))
repo_arg = args.repo or (sorted(named)[0] if named else None)
if args.dry_run:
for id, number, _repo in targets:
iss = issues.get(id)
where = ("%s (state: %s)" % (issue.path_of(root, id), iss.state)
if iss is not None else "no local copy")
print("would %s %s #%d%s" % (verb, id or "?", number, where))
print("%d issue(s) would be %s; no request was made"
% (len(targets), past))
return
login = _gitea.require_login()
base = _gitea.repo_base(repo_arg)
touched = 0
for id, number, _repo in targets:
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH",
{"state": state}, payload_name="state-%d" % number)
# The gate. Above it nothing local has been written; below it the file
# is about to say something the tracker had better agree with.
if not confirmed(got, number, state):
_gitea.die("#%d: %s failed — the tracker's answer does not confirm the "
"write (%.200r). Nothing local was changed."
% (number, verb, got))
print("%s %s #%d %s" % (past, id or "?", number,
got.get("html_url", "")))
iss = issues.get(id)
if iss is None:
print(" no local copy — pull.py %d to get one" % number)
continue
print(" state: %s %s" % (state, apply_state(root, iss, state, got)))
touched += 1
if touched:
path, n = issue_index.build(root)
print("index: %s%d issue(s)" % (path, n))
if __name__ == "__main__":
main()
@@ -73,13 +73,11 @@ def main():
if args.edit: if args.edit:
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH", got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
{"body": body}, payload_name="comment-%d" % args.edit, {"body": body}, payload_name="comment-%d" % args.edit)
out_root=root)
verb = "edited" verb = "edited"
else: else:
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST", got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
{"body": body}, payload_name="comment-%s" % args.id, {"body": body}, payload_name="comment-%s" % args.id)
out_root=root)
verb = "posted" verb = "posted"
if not isinstance(got, dict) or "id" not in got: if not isinstance(got, dict) or "id" not in got:
_gitea.die("%s failed, unexpected response" % verb) _gitea.die("%s failed, unexpected response" % verb)
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
evict.py — ask Gitea which stored issues are closed, then evict those.
evict.py check every synced issue in the store, evict the
ones Gitea says are closed
evict.py old-thing … only these
evict.py --dry-run ask, report, change nothing
The offline command is `/tea:issue`'s `issue_evict.py`, and it is the one that
decides and deletes — this script adds exactly one thing in front of it: a
`state:` that is not stale. A local `state:` is only as fresh as the last pull,
so an issue closed in the web UI an hour ago still reads `open` here and the
offline command will (correctly) leave it alone. That is the gap this closes,
and it is the observed workflow: before this existed the operator had to
`pull.py 11 12 13 14 15` first, which re-wrote the five closed files onto disk
before anything could remove them.
Order of operations, and it is the whole safety argument:
1. every candidate's state is fetched — ALL of them, before anything is
removed;
2. each answer must be an object carrying the number we asked about and a
state from the domain's own vocabulary (`confirmed_state`);
3. only then is the eviction run, by handing the refreshed issues to
`issue_evict.run` — the same decision, the same deletion, the same
protection of `origin: local`, in one place.
A `tea` that will not run, a non-2xx, an answer for another issue, a state
nobody recognizes: the run stops at step 2 and NOTHING is deleted, not even the
issues whose answers had already arrived. That is stricter than `push.py`, which
deletes as it goes, and it costs nothing here — there is no ordering constraint
between evictions, so there is no reason to start before every answer is in.
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
none, is never asked about, and is never evicted — it is not in the tracker to
be closed. An `origin: gitea` issue whose handle is missing or unparseable
cannot be verified, so it is reported and kept rather than guessed at.
Cost: one GET per candidate. The store is a working set that push keeps small,
and a wrong answer here deletes a file, so each issue is asked about by its own
address rather than inferred from a list that a `--limit` could have truncated.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
import _gitea # noqa: E402
import issue # noqa: E402
import issue_evict # noqa: E402
import map as gmap # noqa: E402
def candidates(issues, ids=None):
"""(checkable, unverifiable) — which issues the tracker can be asked about.
checkable is [(id, repo, number)] read off the `gitea:` handle, so an issue
that lives in another repo is asked about there. unverifiable is
[(id, why)]: it names a tracker but carries no handle to reach it by, which
is a file to report, never one to delete on a guess.
An `origin: local` issue is in neither list. It has no handle because it has
never left this machine, and asking Gitea about it is not a question that
has an answer.
"""
checkable, unverifiable = [], []
for id in (list(ids) if ids else sorted(issues)):
iss = issues[id]
if iss.is_local:
continue
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
if not repo or not number:
unverifiable.append((id, "origin: %s but no usable `gitea:` handle"
% iss.origin))
continue
checkable.append((id, repo, number))
return checkable, unverifiable
def confirmed_state(got, number):
"""The state Gitea confirmed for `number`, or None — the deletion gate.
The counterpart of `push.confirmed_number`, and written the same way: boring,
and saying no by default, because everything downstream of a `str` return
here may delete a file. An answer counts only when it is a dict, carries the
very number we asked about, and names a state the domain recognizes.
`bool` is rejected explicitly: `True` is an `int` in Python, and an answer
about issue `true` is not an answer about issue 42.
What it does not have to catch, because it never gets here: a non-2xx or a
`tea` that would not run at all — `_gitea.api` exits on both.
"""
if not isinstance(got, dict):
return None
n = got.get("number")
if isinstance(n, bool) or not isinstance(n, int) or n != number:
return None
state = got.get("state")
return state if state in issue.STATES else None
def main(argv=None):
ap = argparse.ArgumentParser(
description="Evict issues Gitea reports as closed from the local store")
ap.add_argument("ids", nargs="*",
help="issue ids (default: every synced issue in the store)")
ap.add_argument("--dry-run", action="store_true",
help="ask the tracker and report; write and delete nothing")
ap.add_argument("--out", default=issue.ISSUE_ROOT,
help="store root (default: <repo>/tmp/issues)")
args = ap.parse_args(argv)
root = args.out
if not issue.store_exists(root):
_gitea.die("store %s does not exist — nothing to evict" % root)
issues = issue.load_all(root)
missing = [i for i in args.ids if i not in issues]
if missing:
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
checkable, unverifiable = candidates(issues, args.ids)
for id, why in unverifiable:
_gitea.warn("%s: %s — kept, and not asked about" % (id, why))
if not checkable:
print("nothing to check: no issue in the store carries a `gitea:` handle")
return 0
login = _gitea.require_login()
# ---- every answer first, deletions after -----------------------------
fresh = {}
for id, repo, number in checkable:
got = _gitea.api(login, "%s/issues/%d" % (_gitea.repo_base(repo), number))
state = confirmed_state(got, number)
if state is None:
_gitea.die("%s: the tracker's answer for %s#%d does not confirm a state "
"(%.200r). Nothing was evicted."
% (id, repo, number, got))
fresh[id] = state
# The store stops lying even about the issues that stay: an answer already
# paid for is written back when it disagrees with the file. This is the only
# write this script makes, and a dry run makes none.
for id, state in sorted(fresh.items()):
was = issues[id].state
if was == state:
continue
print("state %s %s -> %s" % (id, was, state))
issues[id].state = state
if not args.dry_run:
issue.save(root, issues[id])
issue_evict.run(root, issues, [id for id, _, _ in checkable], args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -32,6 +32,11 @@ created by push as they come up, and deleting or renaming anything at all.
Only repository labels are read; an organization's own labels sit behind a Only repository labels are read; an organization's own labels sit behind a
different endpoint and are neither read nor written. different endpoint and are neither read nor written.
The issue store is out of scope too, and not incidentally. A label belongs to
the repository, not to any issue, so this command neither reads tmp/issues/ nor
creates it the taxonomy it paints comes from the domain MODULE, and the
request bodies it sends go to the transport's own tmp/payload/.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth). Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
""" """
import argparse import argparse
@@ -185,8 +190,7 @@ def main():
continue continue
payload = dict(spec, name=name) payload = dict(spec, name=name)
new = _gitea.api(login, "%s/labels" % base, "POST", payload, new = _gitea.api(login, "%s/labels" % base, "POST", payload,
payload_name="label-%s" % name.replace("/", "-"), payload_name="label-%s" % name.replace("/", "-"))
out_root=issue.ISSUE_ROOT)
if not new or "id" not in new: if not new or "id" not in new:
_gitea.die("could not create label %r" % name) _gitea.die("could not create label %r" % name)
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark)) print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
@@ -211,8 +215,7 @@ def main():
for field, _is, _want in drift: for field, _is, _want in drift:
patch[field] = spec[field] patch[field] = spec[field]
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch, _gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
payload_name="label-%s" % name.replace("/", "-"), payload_name="label-%s" % name.replace("/", "-"))
out_root=issue.ISSUE_ROOT)
fixed += 1 fixed += 1
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown)) print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
@@ -28,11 +28,31 @@ not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI.
A closed issue is not a unit of work, so filter mode enumerates it but leaves A closed issue is not a unit of work, so filter mode enumerates it but leaves
it out of the store: `--state all` still shows the whole picture, and only it out of the store: `--state all` still shows the whole picture, and only
`--state closed` writes one. The limit is on the write, not on the selection `--state closed` writes one. An issue already on disk is refreshed either way,
an issue already on disk is refreshed either way, so the local copy learns it so the local copy learns it was closed instead of staying open forever, and the
was closed instead of staying open forever, and the count of the ones left out count of the ones left out goes to stderr. Key mode is exempt: an address is not
goes to stderr. Key mode is exempt: an address is not a bulk read, and a bulk read, and `pull.py 1` fetches a closed issue as it always did.
`pull.py 1` fetches a closed issue as it always did.
**`--limit` is on the write, not on the selection.** It counts the issues this
run puts in the store written, or left in place by `--cached` and never the
closed ones it enumerated and threw away. `--limit 20` over a milestone whose
first 30 issues are closed still writes 20, if 20 open ones are there to write:
pages keep coming until the budget is full. Two boundaries keep that honest:
- Pages stop the moment the budget is full. Never one page more.
- A filtered read may scan at most `_gitea.PAGE_SLACK` times the pages the limit
would need if nothing were dropped. A filter that matches almost only closed
issues therefore ends in a warning and a short answer, not in a walk of the
whole tracker. Narrow the filter, or raise `--limit`, which raises the budget
with it.
- Dependencies are outside the count: a blocker is followed because a stored
issue named it, not because the filter selected it. `--limit 20` can
therefore leave more than 20 files behind the budget counts the selection's
writes, and the graph is not part of the selection.
`remote.py` is the deliberate exception, and it is not the same flag twice: it
writes nothing at all, so there is no write to bound and its `--limit` means
what it says how many lines to print.
Comments ride along by default, in both modes and for every issue written: Comments ride along by default, in both modes and for every issue written:
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
@@ -42,8 +62,39 @@ from an earlier pull is deleted. An absent file therefore means "no comments",
never "not asked for". The thread is pull-only: editing it changes nothing in never "not asked for". The thread is pull-only: editing it changes nothing in
Gitea (post with comment.py). Gitea (post with comment.py).
**Dependencies come with every pull.** A pull answers with the whole unit of
work the issue and what blocks it so `depends:` is filled from Gitea's
native dependency graph and every blocker is pulled too, recursively, down to
`--depth` (default 3). That graph is the only source there is: `map.from_api`
writes slugs into the `## Depends on` prose and never `#N`, so an edge cannot be
recovered from the body. `--no-deps` turns off both halves no `depends:`, no
recursion, and no request spent on either. `--deps` is still accepted and now
does nothing; it names what already happens.
What it costs, stated rather than hidden:
- **One request per issue that lands in the store** `GET /issues/{n}/dependencies`,
fetched once and used twice, since the same links both fill `depends:` and
tell the walk where to go next. A closed issue that filter mode drops costs
nothing: nothing was stored, so there is no unit of work to complete.
- **One request per blocker the selection did not already carry** a `GET` for
the issue itself, then its own links, and so on until `--depth`.
- So `--milestone X` over 50 open issues is one list request + 50 link requests
+ one pair for every blocker outside the milestone, where it used to be one
request flat. `--no-deps` is the way back to one.
**In filter mode a blocker the filter did not select still lands in the store,
and that is deliberate.** `--milestone X` can leave an issue from milestone Y on
disk and `--label` an unlabelled one: a blocker is followed because a stored
issue names it, not because it matched. The one blocker that does not land is a
closed one closed is not a unit of work, filter mode drops it the way it drops
any other closed issue, and the `depends:` edge to it goes with it, so nothing
points at a file that is not there. Key mode has no such rule and stores it.
Other flags: Other flags:
--deps [--depth N] follow dependencies and pull them too --no-deps do not fill depends:, do not follow blockers
--deps accepted, does nothing: it is the default now
--depth N how deep to follow blockers (default 3)
--cached skip issues already on disk (body AND comments) --cached skip issues already on disk (body AND comments)
--repo owner/repo default: auto-detect from the CWD git remote --repo owner/repo default: auto-detect from the CWD git remote
@@ -52,7 +103,9 @@ have not pushed are lost — with exactly one exception, checkbox state. A `[x]`
on either side wins for any item whose text matches, because a tick is monotone on either side wins for any item whose text matches, because a tick is monotone
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
has the rule and its price). `--cached` skips an issue before any of that: it is has the rule and its price). `--cached` skips an issue before any of that: it is
not read and not merged. Draw the graph afterwards with the domain's own not read and not merged it still costs its one link request, because a cached
issue's blockers can be missing from disk even when it is not (`--cached
--no-deps` is the free one). Draw the graph afterwards with the domain's own
issue_tree.py it needs no network. issue_tree.py it needs no network.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth). Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
@@ -98,6 +151,23 @@ def id_for(payload, store_ids, remote_map, repo, root):
taken=store_ids) taken=store_ids)
def lands_in_store(payload, drop_closed, store_ids, remote_map, repo, root):
"""Would this payload leave a file in the store? The `--limit` predicate.
It has to be the same test the walk below applies, or the budget is spent on
issues that never land which is the bug this exists to prevent. So: a
closed issue counts only when the store already has it (it is refreshed, and
that is a write); anything else counts, including one `--cached` will skip,
because a skipped issue is still an issue the store holds when the run ends.
Cheap in the common case: only a closed payload costs an `id_for`, and that
is a lookup plus, at worst, a stat."""
if not (drop_closed and payload.get("state") == "closed"):
return True
id = id_for(payload, store_ids, remote_map, repo, root)
return os.path.isfile(issue.path_of(root, id))
def comments_path(root, id): def comments_path(root, id):
"""Where an issue's comment thread lives — beside it, under the same slug. """Where an issue's comment thread lives — beside it, under the same slug.
Named in `_gitea` because push.py has to delete the same file.""" Named in `_gitea` because push.py has to delete the same file."""
@@ -131,8 +201,18 @@ def main():
ap.add_argument("-q", "--query", help="search text in title/body") ap.add_argument("-q", "--query", help="search text in title/body")
ap.add_argument("--state", default="open", choices=["open", "closed", "all"], ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
help="filter mode only (default: open)") help="filter mode only (default: open)")
ap.add_argument("--limit", type=int, default=100, help="filter mode cap (default: 100)") ap.add_argument("--limit", type=int, default=100,
ap.add_argument("--deps", action="store_true", help="follow dependencies and pull them") help="filter mode: how many issues to STORE, not to enumerate"
" (default: 100)")
# Dependencies are the default: a pull answers with the unit of work, not
# one row of it. `--deps` stays accepted so the calls and command tables
# written against the old default keep working — it now sets what is
# already set.
ap.add_argument("--no-deps", dest="deps", action="store_false",
help="do not fill depends: and do not follow blockers")
ap.add_argument("--deps", dest="deps", action="store_true",
help="accepted, does nothing: dependencies are followed by default")
ap.set_defaults(deps=True)
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)") ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
ap.add_argument("--cached", action="store_true", ap.add_argument("--cached", action="store_true",
help="skip issues already on disk instead of refetching") help="skip issues already on disk instead of refetching")
@@ -180,9 +260,14 @@ def main():
# ---- seeds ----------------------------------------------------------- # ---- seeds -----------------------------------------------------------
if filtered: if filtered:
# The limit bounds the write, so the transport is told what a write is
# and counts those; the closed ones it enumerated on the way come back
# in the list anyway, to be reported and dropped below.
payloads, ms_title = _gitea.list_issues( payloads, ms_title = _gitea.list_issues(
login, base, state=args.state, labels=args.label, query=args.query, login, base, state=args.state, labels=args.label, query=args.query,
milestone=args.milestone, limit=args.limit) milestone=args.milestone, limit=args.limit,
keep=lambda p: lands_in_store(p, drop_closed, store_ids, remote_map,
repo, root))
if not payloads: if not payloads:
_gitea.die("no issues match that filter") _gitea.die("no issues match that filter")
what = [] what = []
@@ -208,24 +293,32 @@ def main():
stored = os.path.isfile(issue.path_of(root, id)) stored = os.path.isfile(issue.path_of(root, id))
# Closed and not already ours: nothing is written and nothing is asked # Closed and not already ours: nothing is written and nothing is asked
# of the server for it, not even its comments. The slug stays unclaimed # of the server for it not its comments, not its links, and its own
# too, so no other issue ends up pointing `depends:` at a missing file. # blockers are not followed. The slug stays unclaimed too, so no other
# issue ends up pointing `depends:` at a missing file.
if drop_closed and payload.get("state") == "closed" and not stored: if drop_closed and payload.get("state") == "closed" and not stored:
dropped.append(number) dropped.append(number)
else: continue # not stored: no unit of work here, so no links are fetched
store_ids.add(id) store_ids.add(id)
number_of_id[number] = id number_of_id[number] = id
# The native links, fetched ONCE for the two things they are for:
# filling this issue's `depends:` and telling the walk where to go next.
# One request per issue that lands in the store, and only one — the cost
# the docstring quotes is this line.
deps = _gitea.native_deps(login, base, number) if args.deps else []
if args.cached and stored: if args.cached and stored:
skipped.append(id) # untouched, unread, and not one request spent skipped.append(id) # body and thread unread; only the links cost
else: else:
extra = _gitea.native_deps(login, base, number) if args.deps else []
# The copy already on disk, as it was when this run started. It # The copy already on disk, as it was when this run started. It
# contributes its ticked checkboxes and nothing else; None when # contributes its ticked checkboxes and nothing else; None when
# the store has never seen this issue. # the store has never seen this issue.
prev = issues.get(id) prev = issues.get(id)
iss, unresolved = gmap.from_api(payload, id, repo, iss, unresolved = gmap.from_api(payload, id, repo,
id_for_number=number_of_id, id_for_number=number_of_id,
extra_numbers=extra, extra_numbers=deps,
synced=_gitea.now_iso(), synced=_gitea.now_iso(),
local_body=prev.body if prev else None) local_body=prev.body if prev else None)
issue.save(root, iss) issue.save(root, iss)
@@ -235,8 +328,7 @@ def main():
pending.append((id, unresolved)) pending.append((id, unresolved))
if args.deps and depth < args.depth: if args.deps and depth < args.depth:
child_numbers = (gmap.numbers_in_body(payload.get("body") or "") child_numbers = gmap.numbers_in_body(payload.get("body") or "") + deps
+ _gitea.native_deps(login, base, number))
for n in child_numbers: for n in child_numbers:
if n in seen_numbers: if n in seen_numbers:
continue continue
@@ -265,8 +357,10 @@ def main():
# Compact output — the only thing that lands in the model's context. The # Compact output — the only thing that lands in the model's context. The
# thread rides on the issue's own line; no file means no comments. # thread rides on the issue's own line; no file means no comments.
graph = False
for id in sorted(set(written) | set(skipped)): for id in sorted(set(written) | set(skipped)):
iss = issue.load(root, id) iss = issue.load(root, id)
graph = graph or bool(iss.depends)
note = " (cached)" if id in skipped else "" note = " (cached)" if id in skipped else ""
cpath = comments_path(root, id) cpath = comments_path(root, id)
if os.path.isfile(cpath): if os.path.isfile(cpath):
@@ -275,7 +369,9 @@ def main():
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state, id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
issue.path_of(root, id), note)) issue.path_of(root, id), note))
print("index: %s" % index_path) print("index: %s" % index_path)
if args.deps: # Now that dependencies are the default, the hint is worth printing when
# there is something to draw, not on every run that could have drawn it.
if graph:
print("graph: run issue_tree.py (offline) to draw it") print("graph: run issue_tree.py (offline) to draw it")
@@ -44,7 +44,7 @@ way, so nothing is lost, but the tracker shows no edge for it.
The graph goes up with them. Once an issue has its number, every `depends:` The graph goes up with them. Once an issue has its number, every `depends:`
entry that also has one becomes a **native Gitea link** the same entry that also has one becomes a **native Gitea link** the same
`/dependencies` that `pull.py --deps` reads back, so the tracker shows the `/dependencies` that every `pull.py` reads back, so the tracker shows the
blocking panel and refuses to close a blocked issue first. Topological order blocking panel and refuses to close a blocked issue first. Topological order
means the blocker already has its number by then; no second pass is needed. means the blocker already has its number by then; no second pass is needed.
`--update` links whatever appeared in `depends:` since the last push. A link `--update` links whatever appeared in `depends:` since the last push. A link
@@ -334,12 +334,12 @@ def main():
if sent_number: if sent_number:
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True) payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH", got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH",
payload, payload_name="issue-%s" % id, out_root=root) payload, payload_name="issue-%s" % id)
verb = "updated" verb = "updated"
else: else:
payload = gmap.to_payload(iss, label_ids, ms_id) payload = gmap.to_payload(iss, label_ids, ms_id)
got = _gitea.api(login, "%s/issues" % base, "POST", payload, got = _gitea.api(login, "%s/issues" % base, "POST", payload,
payload_name="issue-%s" % id, out_root=root) payload_name="issue-%s" % id)
verb = "created" verb = "created"
# The gate. Below this line the local file is going to be deleted, so # The gate. Below this line the local file is going to be deleted, so
# anything short of a confirmed write has to stop the run here. # anything short of a confirmed write has to stop the run here.
@@ -364,7 +364,7 @@ def main():
if missing: if missing:
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT", _gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]}, {"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
payload_name="labels-%s" % id, out_root=root) payload_name="labels-%s" % id)
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing))) _gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
# The in-memory issue is stamped even though its file is going: the rest # The in-memory issue is stamped even though its file is going: the rest
@@ -392,7 +392,7 @@ def main():
for slug, (drepo, dnum) in wanted_links: for slug, (drepo, dnum) in wanted_links:
if not dnum or (drepo, dnum) in have: if not dnum or (drepo, dnum) in have:
continue continue
if _gitea.add_dependency(login, base, number, drepo, dnum, root): if _gitea.add_dependency(login, base, number, drepo, dnum):
print(" depends on %s#%d (%s)" % (drepo, dnum, slug)) print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
else: else:
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by " _gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
@@ -16,6 +16,11 @@ Usage:
remote.py [--state open|closed|all] [--label L] [-q TEXT] remote.py [--state open|closed|all] [--label L] [-q TEXT]
[--milestone M] [--limit N] [--repo owner/repo] [--milestone M] [--limit N] [--repo owner/repo]
`--limit` here caps the LISTING: N lines out, closed ones among them. That is
not what the same flag means to `pull.py`, and the difference is not an
oversight pull.py bounds what it writes, and this command writes nothing, so
there is nothing else for a limit to bound. Enumeration is the whole job.
Login: the operator's pin from .claude/settings.local.json (see /tea:auth). Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
""" """
import argparse import argparse
@@ -58,6 +58,37 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
per-project by the operator (see `/tea:auth`) and injected by the guard. per-project by the operator (see `/tea:auth`) and injected by the guard.
Config lives in `$XDG_CONFIG_HOME/tea`. Config lives in `$XDG_CONFIG_HOME/tea`.
### `--repo` takes a slug — except where a checkout is required
A few commands touch local git, not just the API, and for those `--repo`
**must be a path to a checkout**; a slug is rejected:
```
Error: local repository required: execute from a repo dir, or specify a path with --repo
```
The message reads like the flag is missing even when it was passed. Confirmed
for `pulls create`, `pulls checkout` and `pulls clean` (tea 0.14.x). Everything
that is only an API call — `pulls list`, `milestones`, `releases`, `times`,
`labels`, `issues` — takes the slug from any directory.
Three working forms for `pulls create`:
```bash
# 1. cwd inside the checkout, no --repo at all
tea pulls create --login "$GITEA_LOGIN" --head feat/x --base main \
--title "…" --description "…"
# 2. from anywhere, --repo as a PATH (this is also the git-worktree answer:
# point it at the main checkout)
tea pulls create --login "$GITEA_LOGIN" --repo /path/to/checkout \
--head feat/x --base main --title "…" --description "…"
# 3. no checkout in reach — POST it, where owner/repo is a slug again
tea api --login "$GITEA_LOGIN" -X POST -d @tmp/pull/x.json \
repos/{owner}/{repo}/pulls
```
## Index ## Index
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats - [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
@@ -125,7 +156,11 @@ are still fine via entity commands. Always the placeholder, never a login name.
## Tips ## Tips
- Pass `-o json` for structured output when parsing programmatically. - Pass `-o json` for structured output when parsing programmatically — on
**entity commands only**. On `tea api`, `-o` is a *file name*: `-o json`
writes the response body to a file called `json` and leaves stdout empty.
The response is already JSON, so there is nothing to format; use `-` for
stdout, or leave the flag off.
- Use `--fields, -f` to narrow columns. - Use `--fields, -f` to narrow columns.
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30). - Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
- If a `tea` command is blocked by `tea-guard`: either you forgot - If a `tea` command is blocked by `tea-guard`: either you forgot
@@ -19,9 +19,16 @@ Without args lists PRs; with `<index>` shows PR detail. Fields: `index,state,aut
Subcommands: Subcommands:
- `list, ls` (`--state`) - `list, ls` (`--state`)
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing. - `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing. Needs a checkout, same as `create`: `--repo` is a path here, not a slug.
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash. - `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash. Needs a checkout, same as `create`.
- `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`). - `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`).
**Needs a local checkout.** `--repo owner/repo` is *not* accepted here — the
slug fails with `local repository required: execute from a repo dir, or
specify a path with --repo`, whose advice reads like the flag was missing.
Run it with cwd inside the checkout and no `--repo`, or pass `--repo
/path/to/checkout`. From a git worktree, point `--repo` at the main
checkout. With no checkout in reach, `POST repos/{owner}/{repo}/pulls`
through `tea api`, which takes the slug.
- `close <idx>...`, `reopen, open <idx>...` - `close <idx>...`, `reopen, open <idx>...`
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`. - `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
- `review <idx>` — interactive review. - `review <idx>` — interactive review.
@@ -26,5 +26,5 @@ Authenticated HTTP request to the Gitea API. Endpoints are auto-prefixed with `/
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`. - `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
- `--header/-H key:value` (repeatable) - `--header/-H key:value` (repeatable)
- `--include/-i` — write status + response headers to stderr. - `--include/-i` — write status + response headers to stderr.
- `--output/-o <file>` — write response body to file (`-` = stdout). - `--output/-o <file>` — write response body to file (`-` = stdout). **Not the entity commands' format flag**: `-o json` here creates a file named `json` and prints nothing. The body is already JSON.
- Quote the endpoint if it contains `?` or `&` to prevent shell expansion. - Quote the endpoint if it contains `?` or `&` to prevent shell expansion.
@@ -14,9 +14,9 @@ Version: `tea 0.14.1` (go-sdk v0.25.1). Source: recursive `--help` traversal. Up
| Flag | Purpose | | Flag | Purpose |
|---|---| |---|---|
| `--login, -l <name>` | use a specific login from the config | | `--login, -l <name>` | use a specific login from the config |
| `--repo, -r <owner/repo>` | override repository context (local path or slug) | | `--repo, -r <owner/repo>` | override repository context (local path or slug). **A slug only works where the command is pure API.** `pulls create`, `pulls checkout` and `pulls clean` need a real checkout and read this flag as a path — see [SKILL.md](../../SKILL.md) |
| `--remote, -R <name>` | discover login from this git remote | | `--remote, -R <name>` | discover login from this git remote |
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json` | | `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json`. **Entity commands only** — on `tea api` the same flag is a FILE NAME, see [HELPERS](./helpers.md) |
| `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) | | `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) |
| `--fields, -f <list>` | which columns to print | | `--fields, -f <list>` | which columns to print |
@@ -255,6 +255,11 @@ class FakeGitea(object):
path, _, query = endpoint.partition("?") path, _, query = endpoint.partition("?")
params = dict(urllib.parse.parse_qsl(query)) params = dict(urllib.parse.parse_qsl(query))
# Every pull asks for an issue's native links now (dependencies are the
# default). Nothing here has any; the answer just has to exist.
if path.endswith("/dependencies"):
return []
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path) m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
if m and method == "GET": if m and method == "GET":
return self.issues.get(int(m.group(1))) return self.issues.get(int(m.group(1)))
+641
View File
@@ -0,0 +1,641 @@
#!/usr/bin/env python3
"""
close.py — the state changes in Gitea, and the local file follows it or nothing
happens at all.
Two halves, and the second is the one that matters:
1. **It closes.** A slug, a number, several of either in one run, and
`--reopen` going the other way. What goes out is a PATCH carrying `state`
and nothing else; what comes back is written into `state:` on the local
file, and the index is rebuilt so the store's own table agrees.
2. **It changes nothing local unless the tracker confirmed it.** A `tea` that
exited non-zero, an answer with no number, an answer for another issue, an
answer that still says `open`, an `origin: local` issue, a `--dry-run`: in
every one of those the file on disk is byte for byte what it was. A bug here
makes the store lie about the tracker, so each path is asserted on its own.
The transport is stubbed at `_gitea.api`, as `test_drop_after_push.py` does,
with the same deliberate exception: the non-2xx test stubs `_gitea.subprocess`
and lets the real `_gitea.api` run, so "tea exited 1" is proved end to end.
Nothing here touches a network, and nothing here touches the developer's store:
every test builds its own in a `tempfile.TemporaryDirectory()`.
"""
import contextlib
import io
import json
import os
import sys
import tempfile
import types
import unittest
from unittest import mock
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
os.path.join(_ROOT, "skills", "issue", "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
import _gitea # noqa: E402
import close # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
# Captured before any test patches it — the non-2xx test needs the real thing.
REAL_API = _gitea.api
REPO = "claude-skills/tea"
BASE = "repos/%s" % REPO
BODY = """## Summary
Прозаическое описание задачи.
## Spec
skills/issue/references/format.md
## Acceptance criteria
- [x] что-нибудь работает
"""
class FakeTracker(object):
"""`tea api` answered from memory, for state writes only.
It keeps a `state` per number and flips it on a PATCH, which is the whole
contract close.py has with the far side."""
def __init__(self):
self.calls = []
self.states = {} # number -> "open" / "closed"
self.raise_on_write = None # an exception instance to raise
self.answer_override = None # what a write answers instead
def payload_of(self, number):
return {"number": number, "state": self.states[number],
"title": "A thing", "updated_at": "2026-08-11T00:00:00Z",
"html_url": "https://git.example/%s/issues/%d" % (REPO, number)}
def writes(self):
return [c for c in self.calls if c[0] != "GET"]
def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False):
self.calls.append((method, endpoint, payload))
path = endpoint.split("?")[0]
if "/issues/" in path and method == "PATCH":
number = int(path.rsplit("/", 1)[1])
if self.raise_on_write is not None:
raise self.raise_on_write
self.states.setdefault(number, "open")
if "state" in (payload or {}):
self.states[number] = payload["state"]
if self.answer_override is not None:
return self.answer_override
return self.payload_of(number)
if "/issues/" in path and method == "GET":
n = int(path.rsplit("/", 1)[1])
return self.payload_of(n) if n in self.states else None
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
class StoreTestCase(unittest.TestCase):
"""A temp store and a fake tracker."""
def setUp(self):
tmp = tempfile.TemporaryDirectory(prefix="tea-close-")
self.addCleanup(tmp.cleanup)
self.root = tmp.name
self.fake = FakeTracker()
for p in (mock.patch.object(_gitea, "api", self.fake.api),
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
p.start()
self.addCleanup(p.stop)
# -- fixtures ----------------------------------------------------------
def synced(self, id="a-thing", number=101, state="open"):
"""An issue that is in the tracker and on disk, the way a pull leaves
it: `origin: gitea`, a `gitea:` field, and a ledger entry."""
key = gmap.remote_key(REPO, number)
iss = issue.Issue(id=id, title="A thing", body=BODY, state=state,
labels=["type/task"], origin=gmap.ORIGIN,
extra={"gitea": key, "url": "https://git.example/x",
"synced": "2026-08-10T00:00:00Z"})
issue.save(self.root, iss)
m = _gitea.load_map(self.root)
m[key] = id
_gitea.save_map(self.root, m)
self.fake.states[number] = state
return iss
def local_only(self, id="local-thing"):
"""An issue that has never left this machine."""
iss = issue.Issue(id=id, title="Local thing", body=BODY,
labels=["type/task"])
issue.save(self.root, iss)
return iss
def dropped(self, id="gone-thing", number=205, state="open"):
"""Pushed, and its file went with the push: ledger only."""
m = _gitea.load_map(self.root)
m[gmap.remote_key(REPO, number)] = id
_gitea.save_map(self.root, m)
self.fake.states[number] = state
return number
# -- runner ------------------------------------------------------------
def run_close(self, *argv):
self.out, self.err = io.StringIO(), io.StringIO()
args = ["close.py", "--repo", REPO, "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(self.out), \
contextlib.redirect_stderr(self.err):
close.main()
return self.out.getvalue(), self.err.getvalue()
# -- assertions --------------------------------------------------------
def state_on_disk(self, id):
return issue.load(self.root, id).state
def raw(self, id):
with open(issue.path_of(self.root, id)) as f:
return f.read()
def assertUnchanged(self, id, before, why=""):
self.assertEqual(self.raw(id), before,
"%s.md was rewritten%s" % (id, why and "" + why))
# --------------------------------------------------------------------------
# it closes
# --------------------------------------------------------------------------
class ClosesTest(StoreTestCase):
def test_a_slug_closes_the_issue_it_names(self):
self.synced("a-thing", 101)
out, _ = self.run_close("a-thing")
self.assertEqual(self.fake.states[101], "closed")
self.assertIn("closed a-thing #101", out)
def test_the_local_state_follows(self):
self.synced("a-thing", 101)
self.run_close("a-thing")
self.assertEqual(self.state_on_disk("a-thing"), "closed")
def test_only_the_state_is_sent(self):
"""Closing is not an edit: no title, no body, no labels ride along."""
self.synced("a-thing", 101)
self.run_close("a-thing")
writes = self.fake.writes()
self.assertEqual(len(writes), 1)
method, endpoint, payload = writes[0]
self.assertEqual((method, endpoint), ("PATCH", "%s/issues/101" % BASE))
self.assertEqual(payload, {"state": "closed"})
def test_a_number_closes_it_too(self):
"""The normal case for a pushed issue — the file is long gone."""
self.synced("a-thing", 101)
self.run_close("101")
self.assertEqual(self.fake.states[101], "closed")
self.assertEqual(self.state_on_disk("a-thing"), "closed")
def test_every_key_form_is_accepted(self):
forms = {110: "110", 111: "#111", 112: "%s#112" % REPO,
113: "https://git.example/%s/issues/113" % REPO}
for n in forms:
self.fake.states[n] = "open"
for n, arg in forms.items():
with self.subTest(arg=arg):
self.run_close(arg)
self.assertEqual(self.fake.states[n], "closed")
def test_several_ids_in_one_run(self):
self.synced("a-thing", 101)
self.synced("b-thing", 102)
self.run_close("a-thing", "102")
self.assertEqual(self.fake.states, {101: "closed", 102: "closed"})
self.assertEqual(self.state_on_disk("a-thing"), "closed")
self.assertEqual(self.state_on_disk("b-thing"), "closed")
def test_the_same_issue_named_twice_is_written_once(self):
self.synced("a-thing", 101)
self.run_close("a-thing", "#101")
self.assertEqual(len(self.fake.writes()), 1)
def test_the_index_is_rebuilt(self):
self.synced("a-thing", 101)
out, _ = self.run_close("a-thing")
self.assertIn("index:", out)
with open(os.path.join(self.root, "INDEX.md")) as f:
self.assertIn("closed", f.read())
def test_the_body_survives_untouched(self):
"""One metadata field changes; the prose and the ticks do not."""
self.synced("a-thing", 101)
before = issue.load(self.root, "a-thing").body
self.run_close("a-thing")
self.assertEqual(issue.load(self.root, "a-thing").body, before)
def test_synced_is_refreshed(self):
self.synced("a-thing", 101)
self.run_close("a-thing")
iss = issue.load(self.root, "a-thing")
self.assertNotEqual(iss.extra.get("synced"), "2026-08-10T00:00:00Z")
self.assertEqual(iss.extra.get("remote-updated"), "2026-08-11T00:00:00Z")
def test_an_issue_whose_file_was_dropped_still_closes(self):
"""No local copy at all: the ledger names it, the tracker takes it, and
nothing is written locally."""
self.dropped("gone-thing", 205)
out, _ = self.run_close("gone-thing")
self.assertEqual(self.fake.states[205], "closed")
self.assertIn("no local copy", out)
self.assertNotIn("index:", out)
def test_a_number_nobody_here_knows_closes_without_a_slug(self):
self.fake.states[777] = "open"
out, _ = self.run_close("777")
self.assertEqual(self.fake.states[777], "closed")
self.assertIn("#777", out)
class ReopensTest(StoreTestCase):
def test_reopen_sends_open(self):
self.synced("a-thing", 101, state="closed")
out, _ = self.run_close("--reopen", "a-thing")
self.assertEqual(self.fake.writes()[0][2], {"state": "open"})
self.assertIn("reopened a-thing #101", out)
def test_reopen_writes_the_local_state_back(self):
self.synced("a-thing", 101, state="closed")
self.run_close("--reopen", "a-thing")
self.assertEqual(self.state_on_disk("a-thing"), "open")
def test_close_then_reopen_is_a_round_trip(self):
self.synced("a-thing", 101)
self.run_close("a-thing")
self.run_close("--reopen", "a-thing")
self.assertEqual(self.fake.states[101], "open")
self.assertEqual(self.state_on_disk("a-thing"), "open")
# --------------------------------------------------------------------------
# it refuses
# --------------------------------------------------------------------------
class LocalOnlyTest(StoreTestCase):
"""An `origin: local` issue is not in the tracker, so it cannot be closed
there — and the local field is not quietly edited instead."""
def test_it_exits(self):
self.local_only("local-thing")
with self.assertRaises(SystemExit):
self.run_close("local-thing")
def test_the_error_names_the_id_and_says_it_is_not_in_the_tracker(self):
self.local_only("local-thing")
with self.assertRaises(SystemExit):
self.run_close("local-thing")
err = self.err.getvalue()
self.assertIn("local-thing", err)
self.assertIn("not in the tracker", err)
def test_nothing_is_sent(self):
self.local_only("local-thing")
with self.assertRaises(SystemExit):
self.run_close("local-thing")
self.assertEqual(self.fake.calls, [])
def test_the_file_is_untouched(self):
self.local_only("local-thing")
before = self.raw("local-thing")
with self.assertRaises(SystemExit):
self.run_close("local-thing")
self.assertUnchanged("local-thing", before)
def test_a_bad_id_stops_the_whole_run_before_anything_is_sent(self):
"""Resolution happens up front, so a typo in the second id does not
leave the first one closed."""
self.synced("a-thing", 101)
with self.assertRaises(SystemExit):
self.run_close("a-thing", "local-thing")
self.assertEqual(self.fake.states[101], "open")
self.assertEqual(self.fake.calls, [])
def test_an_unknown_slug_exits(self):
with self.assertRaises(SystemExit):
self.run_close("no-such-thing")
self.assertIn("no-such-thing", self.err.getvalue())
class DryRunTest(StoreTestCase):
def test_not_one_request_is_made(self):
self.synced("a-thing", 101)
self.run_close("--dry-run", "a-thing")
self.assertEqual(self.fake.calls, [])
def test_the_file_is_untouched(self):
self.synced("a-thing", 101)
before = self.raw("a-thing")
self.run_close("--dry-run", "a-thing")
self.assertUnchanged("a-thing", before, "--dry-run must write nothing")
def test_it_says_what_would_be_closed(self):
self.synced("a-thing", 101)
self.synced("b-thing", 102)
out, _ = self.run_close("--dry-run", "a-thing", "102")
self.assertIn("would close a-thing #101", out)
self.assertIn("would close b-thing #102", out)
self.assertIn("2 issue(s) would be closed", out)
def test_it_says_reopen_under_reopen(self):
self.synced("a-thing", 101, state="closed")
out, _ = self.run_close("--dry-run", "--reopen", "a-thing")
self.assertIn("would reopen a-thing #101", out)
self.assertIn("would be reopened", out)
def test_it_needs_no_login(self):
"""A dry run must work before /tea:auth has ever been run."""
self.synced("a-thing", 101)
with mock.patch.object(_gitea, "require_login",
lambda: self.fail("dry run asked for a login")):
self.run_close("--dry-run", "a-thing")
def test_a_local_only_issue_is_still_refused(self):
self.local_only("local-thing")
with self.assertRaises(SystemExit):
self.run_close("--dry-run", "local-thing")
# --------------------------------------------------------------------------
# the tracker said no
# --------------------------------------------------------------------------
class TrackerFailureTest(StoreTestCase):
"""The criterion that matters most: a write that was not confirmed leaves
the local file exactly as it was."""
def test_a_non_2xx_answer_leaves_the_file(self):
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
or a 500 actually takes, and it ends in `die()`."""
self.synced("a-thing", 101)
before = self.raw("a-thing")
def fake_run(cmd, capture_output=False, text=False):
return types.SimpleNamespace(
returncode=1, stdout="",
stderr="422 Unprocessable Entity: issue is blocked")
with mock.patch.object(_gitea, "api", REAL_API), \
mock.patch.object(_gitea, "subprocess",
types.SimpleNamespace(run=fake_run)), \
self.assertRaises(SystemExit):
self.run_close("a-thing")
self.assertUnchanged("a-thing", before, "tea exited non-zero")
self.assertEqual(self.state_on_disk("a-thing"), "open")
def test_a_transport_exception_leaves_the_file(self):
self.synced("a-thing", 101)
before = self.raw("a-thing")
self.fake.raise_on_write = OSError("tea: command not found")
with self.assertRaises(OSError):
self.run_close("a-thing")
self.assertUnchanged("a-thing", before, "the transport raised")
def test_an_answer_without_a_number_leaves_the_file(self):
self.synced("a-thing", 101)
before = self.raw("a-thing")
self.fake.answer_override = {"ok": True, "state": "closed"}
with self.assertRaises(SystemExit):
self.run_close("a-thing")
self.assertUnchanged("a-thing", before)
def test_an_answer_for_another_issue_leaves_the_file(self):
self.synced("a-thing", 101)
before = self.raw("a-thing")
self.fake.answer_override = {"number": 999, "state": "closed"}
with self.assertRaises(SystemExit):
self.run_close("a-thing")
self.assertUnchanged("a-thing", before)
def test_an_answer_that_did_not_change_the_state_leaves_the_file(self):
"""A 200 that still says `open` is not a close."""
self.synced("a-thing", 101)
before = self.raw("a-thing")
self.fake.answer_override = {"number": 101, "state": "open"}
with self.assertRaises(SystemExit):
self.run_close("a-thing")
self.assertUnchanged("a-thing", before)
def test_an_empty_answer_leaves_the_file(self):
self.synced("a-thing", 101)
before = self.raw("a-thing")
self.fake.answer_override = None
real_api = self.fake.api
self.fake.api = lambda *a, **kw: (real_api(*a, **kw), None)[1]
with mock.patch.object(_gitea, "api", self.fake.api), \
self.assertRaises(SystemExit):
self.run_close("a-thing")
self.assertUnchanged("a-thing", before)
def test_the_error_says_nothing_local_changed(self):
self.synced("a-thing", 101)
self.fake.answer_override = {"ok": True}
with self.assertRaises(SystemExit):
self.run_close("a-thing")
self.assertIn("Nothing local was changed", self.err.getvalue())
def test_a_failure_partway_through_keeps_the_rest(self):
"""Two issues, the second one is not confirmed. The first is
legitimately closed; the second's file still says open."""
self.synced("aaa-thing", 101)
self.synced("zzz-thing", 102)
before = self.raw("zzz-thing")
real = self.fake.api
seen = []
def once(login, endpoint, method="GET", payload=None, **kw):
got = real(login, endpoint, method, payload, **kw)
if method != "GET":
seen.append(endpoint)
return {"nope": True} if len(seen) > 1 else got
with mock.patch.object(_gitea, "api", once), \
self.assertRaises(SystemExit):
self.run_close("aaa-thing", "zzz-thing")
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
self.assertUnchanged("zzz-thing", before, "its write was not confirmed")
# --------------------------------------------------------------------------
# the pure parts
# --------------------------------------------------------------------------
class ConfirmedTest(unittest.TestCase):
"""The gate itself. Everything below it rewrites a file."""
def test_a_matching_close_is_confirmed(self):
self.assertTrue(close.confirmed({"number": 42, "state": "closed"}, 42, "closed"))
def test_a_mismatched_number_is_not(self):
self.assertFalse(close.confirmed({"number": 43, "state": "closed"}, 42, "closed"))
def test_the_wrong_state_is_not(self):
self.assertFalse(close.confirmed({"number": 42, "state": "open"}, 42, "closed"))
def test_a_missing_state_is_not(self):
self.assertFalse(close.confirmed({"number": 42}, 42, "closed"))
def test_none_and_lists_are_not(self):
self.assertFalse(close.confirmed(None, 42, "closed"))
self.assertFalse(close.confirmed([{"number": 42, "state": "closed"}], 42, "closed"))
def test_true_is_not_a_number(self):
self.assertFalse(close.confirmed({"number": True, "state": "closed"}, 1, "closed"))
def test_a_string_number_is_not(self):
self.assertFalse(close.confirmed({"number": "42", "state": "closed"}, 42, "closed"))
class KeyFormTest(unittest.TestCase):
"""A slug and a key are two vocabularies that must not collide."""
def test_keys_are_keys(self):
for k in ("42", "#42", "owner/repo#42",
"https://git.example/owner/repo/issues/42"):
self.assertTrue(close.looks_like_key(k), k)
def test_slugs_are_not_keys(self):
for s in ("a-thing", "wire-sqlc-appclick", "close-issues-through-a-script"):
self.assertFalse(close.looks_like_key(s), s)
class LedgerPairsTest(unittest.TestCase):
def setUp(self):
self.m = {"%s#7" % REPO: "a-thing", "other/repo#7": "b-thing",
"not-a-key": "c-thing"}
def test_it_filters_by_repo(self):
self.assertEqual(close.ledger_pairs(self.m, REPO), [(REPO, 7, "a-thing")])
def test_without_a_repo_it_keeps_everything_parseable(self):
got = close.ledger_pairs(self.m)
self.assertEqual(sorted(s for _r, _n, s in got), ["a-thing", "b-thing"])
def test_an_ambiguous_number_exits(self):
pairs = close.ledger_pairs(self.m)
with self.assertRaises(SystemExit):
with contextlib.redirect_stderr(io.StringIO()):
close.resolve("7", {}, pairs)
class AmbiguityTest(StoreTestCase):
"""Two repos, one number, no --repo: settle it rather than guess."""
def test_the_error_points_at_repo(self):
_gitea.save_map(self.root, {"%s#7" % REPO: "a-thing",
"other/repo#7": "b-thing"})
err = io.StringIO()
args = ["close.py", "--out", self.root, "7"]
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(err), \
self.assertRaises(SystemExit):
close.main()
self.assertIn("--repo", err.getvalue())
class RepoOfTheKeyTest(StoreTestCase):
"""A key that names its own repo is sent there, not to whatever repo the
CWD happens to be — otherwise `#42` closes somebody else's issue."""
def run_bare(self, *argv):
"""No `--repo`, so the ids have to say where they live."""
self.out, self.err = io.StringIO(), io.StringIO()
args = ["close.py", "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(self.out), \
contextlib.redirect_stderr(self.err):
close.main()
return self.out.getvalue(), self.err.getvalue()
def test_a_foreign_key_goes_to_its_own_repo(self):
self.run_bare("other/repo#42")
self.assertEqual(self.fake.writes()[0][1], "repos/other/repo/issues/42")
def test_a_slug_goes_to_the_repo_its_gitea_field_names(self):
self.synced("a-thing", 101)
self.run_bare("a-thing")
self.assertEqual(self.fake.writes()[0][1], "%s/issues/101" % BASE)
def test_two_repos_in_one_run_is_a_question_not_a_guess(self):
self.synced("a-thing", 101)
with self.assertRaises(SystemExit):
self.run_bare("a-thing", "other/repo#42")
self.assertIn("one repo", self.err.getvalue())
self.assertEqual(self.fake.calls, [])
def test_an_explicit_repo_settles_it(self):
self.synced("a-thing", 101)
self.run_close("a-thing", "other/repo#42")
self.assertEqual({c[1] for c in self.fake.writes()},
{"%s/issues/101" % BASE, "%s/issues/42" % BASE})
class NoStoreTest(StoreTestCase):
"""A number needs no local file, and a store that is not there is not an
error — closing an issue whose copy push dropped is the normal case."""
def test_a_number_closes_with_no_store_at_all(self):
missing = os.path.join(self.root, "nowhere")
self.fake.states[303] = "open"
args = ["close.py", "--repo", REPO, "--out", missing, "303"]
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
close.main()
self.assertEqual(self.fake.states[303], "closed")
self.assertFalse(os.path.isdir(missing), "no store was conjured")
class PayloadFileTest(StoreTestCase):
"""The request body goes to the transport's own scratchpad.
Not to a directory this script picks: `close.py` names the payload and
nothing else, the way every other caller does. Where PAYLOAD_ROOT lands is
_gitea's business, and test_payload_root.py is where that is tested."""
def test_the_payload_lands_in_the_transports_scratchpad(self):
self.synced("a-thing", 101)
payloads = os.path.join(self.root, "payload")
with mock.patch.object(_gitea, "PAYLOAD_ROOT", payloads), \
mock.patch.object(_gitea, "api", REAL_API), \
mock.patch.object(
_gitea, "subprocess",
types.SimpleNamespace(run=lambda cmd, **kw: types.SimpleNamespace(
returncode=0, stderr="",
stdout=json.dumps({"number": 101, "state": "closed"})))):
self.run_close("a-thing")
p = os.path.join(payloads, "state-101.json")
self.assertTrue(os.path.isfile(p))
with open(p) as f:
self.assertEqual(json.load(f), {"state": "closed"})
if __name__ == "__main__":
unittest.main()
@@ -124,7 +124,7 @@ class FakeTracker(object):
# -- the seam ---------------------------------------------------------- # -- the seam ----------------------------------------------------------
def api(self, login, endpoint, method="GET", payload=None, def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False): payload_name=None, allow_fail=False):
self.calls.append((method, endpoint, payload)) self.calls.append((method, endpoint, payload))
path = endpoint.split("?")[0] path = endpoint.split("?")[0]
@@ -194,7 +194,13 @@ class StoreTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
self.root = tempfile.mkdtemp(prefix="tea-drop-") self.root = tempfile.mkdtemp(prefix="tea-drop-")
self.fake = FakeTracker() self.fake = FakeTracker()
# PAYLOAD_ROOT is the repo's own tmp/payload, and a test that stubs the
# transport one layer down (see the non-2xx case) reaches the real
# write. Point it at the fixture: a test writes in its temp directory
# and nowhere else.
for p in (mock.patch.object(_gitea, "api", self.fake.api), for p in (mock.patch.object(_gitea, "api", self.fake.api),
mock.patch.object(_gitea, "PAYLOAD_ROOT",
os.path.join(self.root, "payload")),
mock.patch.object(_gitea, "require_login", lambda: "test-login"), mock.patch.object(_gitea, "require_login", lambda: "test-login"),
mock.patch.object(push, "git_branch", lambda: "test-branch")): mock.patch.object(push, "git_branch", lambda: "test-branch")):
p.start() p.start()
+570
View File
@@ -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()
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""
Where the login pin is found, and that a git worktree is not a dead zone.
python3 -m unittest discover -s tests -v
Stdlib unittest, no third-party anything, and not one real network call: every
run here is against a throwaway repository with a FAKE `tea` first on PATH.
The bug: the pin was searched for by walking up from CWD only. A worktree is a
*sibling* of the main checkout, and `.claude/settings.local.json` is untracked,
so it lives in the main checkout and nowhere else — the whole sync layer died
inside any worktree with "no login pinned", while `tea` in the same directory
worked, because the tea-guard hook had a second, different copy of the search.
So these tests hold two lines at once: the pin is reachable from a worktree,
and the hook and the scripts get their answer from the same function.
"""
import json
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
HOOKS = os.path.join(REPO, "hooks")
sys.path.insert(0, AUTH_SCRIPTS)
import pin # noqa: E402
HAVE_GIT = shutil.which("git") is not None
LOGIN = "fixture/user"
ENV_KEY = pin.ENV_KEY
# A `tea` that answers without a network: an empty list for every GET, a
# created object for every write. It records its own argv, which is how a test
# reads back the login the call actually ran under.
FAKE_TEA = '''#!%s
import json, os, sys
argv = sys.argv[1:]
with open(os.environ["TEA_CALL_LOG"], "a") as f:
f.write("\\t".join(argv) + "\\n")
sys.stdout.write(json.dumps({"id": 1, "number": 101, "name": "created",
"html_url": "https://example.invalid/issues/101",
"labels": []})
if "-X" in argv else "[]")
'''
ISSUE = """\
---
id: pinned-work
state: open
labels: [type/task]
assignees: []
milestone: none
depends: []
origin: local
---
# Pinned work
## Summary
Issue фикстуры, живёт в сторе worktree.
## Spec
none
## Motivation
Нужен, чтобы push.py было что отправить.
## Acceptance criteria
- [ ] проверяемое условие
"""
def write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(text)
class Worktree(object):
"""A repository with a pin, and a linked worktree beside it.
Beside, not below: `main/` and `worktrees/feature/` are siblings, which is
the entire shape of the bug. The pin is written after the clone is
committed and is covered by .gitignore, so it exists in the main checkout
only — exactly as `/tea:auth` leaves it."""
def __init__(self, pinned=LOGIN):
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-")
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
# own cwd would otherwise disagree with the path we handed it.
self.root = os.path.realpath(self._tmp.name)
self.main = os.path.join(self.root, "main")
self.tree = os.path.join(self.root, "worktrees", "feature")
self.calls = os.path.join(self.root, "calls.txt")
skip = shutil.ignore_patterns("__pycache__")
for layer in ("auth", "issue", "sync"):
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
os.path.join(self.main, "skills", layer, "scripts"),
ignore=skip)
shutil.copytree(HOOKS, os.path.join(self.main, "hooks"), ignore=skip)
write(os.path.join(self.main, ".gitignore"), "tmp/\n.claude/\n")
self.bin = os.path.join(self.root, "fakebin")
os.makedirs(self.bin)
tea = os.path.join(self.bin, "tea")
write(tea, FAKE_TEA % sys.executable)
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
self.git("init", cwd=self.main)
self.git("add", "-A", cwd=self.main)
self.git("commit", "-m", "fixture", cwd=self.main)
self.git("worktree", "add", "-b", "feature", self.tree, cwd=self.main)
if pinned:
write(os.path.join(self.main, ".claude", "settings.local.json"),
json.dumps({"env": {ENV_KEY: pinned}}))
def cleanup(self):
self._tmp.cleanup()
def env(self):
env = dict(os.environ)
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
# The start of the search order, cleared: this fixture is about the
# steps *after* it, and the developer's own project must not answer.
env.pop(pin.PROJECT_DIR_ENV, None)
env["PATH"] = self.bin + os.pathsep + env["PATH"]
env["TEA_CALL_LOG"] = self.calls
env["HOME"] = self.root # keep the developer's git config out
env["GIT_CONFIG_NOSYSTEM"] = "1"
env["GIT_CONFIG_GLOBAL"] = os.devnull
return env
def git(self, *args, **kw):
cmd = ["git", "-c", "user.email=fixture@example.invalid",
"-c", "user.name=fixture", "-c", "commit.gpgsign=false"] + list(args)
p = subprocess.run(cmd, cwd=kw.pop("cwd", self.tree), env=self.env(),
capture_output=True, text=True)
if p.returncode != 0:
raise AssertionError("%s failed:\n%s%s" % (" ".join(cmd), p.stdout, p.stderr))
return p.stdout.strip()
def script(self, layer, name):
"""A script as the WORKTREE sees it — the copy the operator would run."""
return os.path.join(self.tree, "skills", layer, "scripts", name)
def run(self, script, *args, **kw):
p = subprocess.run([sys.executable, script] + list(args),
cwd=kw.pop("cwd", self.tree), env=self.env(),
capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
def tea_calls(self):
if not os.path.isfile(self.calls):
return []
with open(self.calls) as f:
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
def logins_used(self):
return [a[a.index("--login") + 1] for a in self.tea_calls() if "--login" in a]
# --------------------------------------------------------------------------
# the search itself
# --------------------------------------------------------------------------
class TestSearch(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-unit-")
self.root = os.path.realpath(self._tmp.name)
self.addCleanup(self._tmp.cleanup)
def path(self, *parts):
return os.path.join(self.root, *parts)
def pin_at(self, root, login=LOGIN):
write(os.path.join(root, ".claude", "settings.local.json"),
json.dumps({"env": {ENV_KEY: login}}))
def test_the_parent_chain_is_searched(self):
self.pin_at(self.root)
os.makedirs(self.path("a", "b"))
self.assertEqual(pin.search(self.path("a", "b"))[0], LOGIN)
def test_no_pin_is_no_pin(self):
os.makedirs(self.path("a"))
self.assertEqual(pin.search(self.path("a")), (None, None))
def test_an_unreadable_pin_is_not_a_login(self):
write(self.path(".claude", "settings.local.json"), "{ not json")
self.assertEqual(pin.search(self.root), (None, None))
def test_an_empty_pin_is_not_a_login(self):
write(self.path(".claude", "settings.local.json"),
json.dumps({"env": {ENV_KEY: " "}}))
self.assertEqual(pin.search(self.root), (None, None))
def test_a_git_file_pointing_at_a_worktree_reaches_the_main_checkout(self):
"""The hop, built by hand from the two files git writes — no git
needed to state what the layout means."""
main, tree = self.path("main"), self.path("elsewhere", "feature")
gitdir = os.path.join(main, ".git", "worktrees", "feature")
os.makedirs(gitdir)
os.makedirs(tree)
write(os.path.join(gitdir, "commondir"), "../..\n")
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
self.pin_at(main)
self.assertEqual(pin.main_worktree(tree), main)
login, src = pin.search(tree)
self.assertEqual(login, LOGIN)
self.assertEqual(src, pin.settings_path(main))
def test_an_ordinary_clone_is_not_a_worktree(self):
os.makedirs(self.path("clone", ".git"))
self.assertIsNone(pin.main_worktree(self.path("clone")))
def test_a_submodule_pointer_is_not_a_worktree(self):
"""`.git` is a file there too, but it points into .git/modules/… and
the tree it belongs to is already on the parent chain."""
sub = self.path("super", "lib")
gitdir = self.path("super", ".git", "modules", "lib")
os.makedirs(gitdir)
os.makedirs(sub)
write(os.path.join(sub, ".git"), "gitdir: %s\n" % gitdir)
self.assertIsNone(pin.main_worktree(sub))
def test_the_chain_wins_over_the_hop(self):
"""The worktree branch may only find a pin the walk up would have
missed entirely — it never overrides a nearer one."""
main, tree = self.path("main"), self.path("elsewhere", "feature")
gitdir = os.path.join(main, ".git", "worktrees", "feature")
os.makedirs(gitdir)
os.makedirs(tree)
write(os.path.join(gitdir, "commondir"), "../..\n")
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
self.pin_at(main, "main/login")
self.pin_at(tree, "worktree/login")
self.assertEqual(pin.search(tree)[0], "worktree/login")
def test_start_dirs_are_ordered_and_deduplicated(self):
with mock.patch.dict(os.environ, {pin.PROJECT_DIR_ENV: self.path("p")}):
self.assertEqual(pin.start_dirs(self.path("h")),
[self.path("p"), self.path("h"),
os.path.abspath(os.getcwd())])
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(pin.start_dirs(), [os.path.abspath(os.getcwd())])
# --------------------------------------------------------------------------
# a script run from a worktree
# --------------------------------------------------------------------------
@unittest.skipUnless(HAVE_GIT, "git is not installed")
class TestScriptsInAWorktree(unittest.TestCase):
def setUp(self):
self.wt = Worktree()
self.addCleanup(self.wt.cleanup)
def test_a_sync_script_run_from_the_worktree_finds_the_login(self):
"""The acceptance criterion, run for real: cwd inside the worktree,
the pin in the main checkout, and the call goes out under it."""
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
"--repo", "fixture/repo", "--state", "all")
self.assertEqual(rc, 0, "remote.py failed:\n%s%s" % (out, err))
self.assertNotIn("no login pinned", err)
self.assertEqual(self.wt.logins_used(), [LOGIN])
def test_it_does_not_pin_a_second_login_in_the_worktree(self):
"""Nothing here writes a settings file, and the worktree is the last
place one should appear: it is deleted with the worktree."""
self.wt.run(self.wt.script("sync", "remote.py"), "--repo", "fixture/repo")
self.assertFalse(os.path.exists(pin.settings_path(self.wt.tree)),
"a second settings.local.json appeared in the worktree")
def test_with_no_pin_anywhere_it_still_says_so(self):
wt = Worktree(pinned=None)
self.addCleanup(wt.cleanup)
rc, out, err = wt.run(wt.script("sync", "remote.py"), "--repo", "fixture/repo")
self.assertNotEqual(rc, 0)
self.assertIn("no login pinned", err)
self.assertEqual(wt.logins_used(), [])
def test_the_scripts_own_directory_is_not_a_pin_source(self):
"""Run the worktree's script from a directory that is in no pinned
tree. The script sits inside a repository that has a pin — and it must
still refuse, because the pin belongs to the project being worked on,
not to the installation."""
outside = os.path.join(self.wt.root, "outside")
os.makedirs(outside)
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
"--repo", "fixture/repo", cwd=outside)
self.assertNotEqual(rc, 0)
self.assertIn("no login pinned", err)
def test_push_from_a_worktree_sends_the_worktree_branch(self):
"""`branch:` -> Gitea `ref`. The workaround this fix removes — run the
worktree's scripts with cwd in the main checkout — sent the main
checkout's branch, which is the one field `branch:` exists for."""
write(os.path.join(self.wt.tree, "tmp", "issues", "pinned-work.md"), ISSUE)
rc, out, err = self.wt.run(self.wt.script("sync", "push.py"),
"pinned-work", "--repo", "fixture/repo")
self.assertEqual(rc, 0, "push.py failed:\n%s%s" % (out, err))
self.assertIn("created pinned-work #101", out)
with open(os.path.join(self.wt.tree, "tmp", "payload",
"issue-pinned-work.json")) as f:
payload = json.load(f)
self.assertEqual(payload.get("ref"), "feature")
self.assertEqual(self.wt.git("rev-parse", "--abbrev-ref", "HEAD"), "feature")
self.assertNotEqual(
self.wt.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.wt.main),
"feature", "the fixture's two trees are on the same branch")
# --------------------------------------------------------------------------
# one order, one copy of it
# --------------------------------------------------------------------------
@unittest.skipUnless(HAVE_GIT, "git is not installed")
class TestTheHookAndTheScriptsAgree(unittest.TestCase):
def setUp(self):
self.wt = Worktree()
self.addCleanup(self.wt.cleanup)
def guard(self, cwd):
"""The hook, as the harness calls it: payload on stdin, decision on
stdout."""
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
"cwd": cwd}
p = subprocess.run([sys.executable, os.path.join(self.wt.tree, "hooks",
"tea-guard.sh")],
input=json.dumps(payload), cwd=cwd, env=self.wt.env(),
capture_output=True, text=True)
return p
def test_the_hook_resolves_the_pin_from_the_worktree_too(self):
p = self.guard(self.wt.tree)
self.assertEqual(p.returncode, 0, p.stderr)
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
self.assertIn(LOGIN, got)
self.assertNotIn("GITEA_LOGIN", got)
def test_the_hook_and_a_script_answer_the_same_directory_alike(self):
"""The regression that started this: in one directory the hook
resolved the login and every script said there was none."""
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
"--repo", "fixture/repo")
self.assertEqual(rc, 0, err)
script_login = self.wt.logins_used()[0]
hook_login = json.loads(self.guard(self.wt.tree).stdout)[
"hookSpecificOutput"]["updatedInput"]["command"].split("--login ")[1].split()[0]
self.assertEqual(hook_login, script_login)
def test_the_hook_still_blocks_when_nothing_is_pinned(self):
wt = Worktree(pinned=None)
self.addCleanup(wt.cleanup)
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
"cwd": wt.tree}
p = subprocess.run([sys.executable, os.path.join(wt.tree, "hooks", "tea-guard.sh")],
input=json.dumps(payload), cwd=wt.tree, env=wt.env(),
capture_output=True, text=True)
self.assertEqual(p.returncode, 2)
self.assertIn("no login is pinned", p.stderr)
class TestNobodyKeepsASecondCopy(unittest.TestCase):
"""Mechanical: the search order is written in pin.py, and the two callers
spell neither the path nor the walk."""
CALLERS = (os.path.join(HOOKS, "tea-guard.sh"),
os.path.join(SYNC_SCRIPTS, "_gitea.py"))
def source(self, path):
with open(path) as f:
return f.read()
def test_the_path_is_spelled_once(self):
self.assertEqual(pin.SETTINGS_PARTS, (".claude", "settings.local.json"))
for path in self.CALLERS:
body = self.source(path)
for literal in ('".claude"', "'.claude'"):
self.assertNotIn(literal, body,
"%s builds the settings path itself" % path)
def test_both_callers_go_through_the_module(self):
for path in self.CALLERS:
self.assertIn("import pin", self.source(path),
"%s does not resolve the pin through pin.py" % path)
def test_the_domain_layer_never_learns_what_a_login_is(self):
"""The layer rule, unchanged by this: the identity module is imported
by the bridge and by the hook, never by a domain."""
for layer in ("issue", "page"):
d = os.path.join(REPO, "skills", layer, "scripts")
for name in sorted(os.listdir(d)):
if not name.endswith(".py"):
continue
body = self.source(os.path.join(d, name))
for banned in ("import pin", "GITEA_LOGIN", "settings.local.json"):
self.assertNotIn(banned, body, "%s/%s: %s" % (layer, name, banned))
if __name__ == "__main__":
unittest.main()
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Where request bodies land, and that writing one never conjures a store.
python3 -m unittest discover -s tests -v
Stdlib unittest, no third-party anything. The bug these tests pin down:
`labels.py --bootstrap` on a fresh checkout left `tmp/issues/.payload/` behind,
because the only place `_gitea.api` had to put a request file was whatever root
the caller handed it — and the label bootstrap, which touches no issue at all,
handed it the issue store. A store materialized as a side effect of an
operation that has nothing to do with issues.
Every run here is against a throwaway repository with a FAKE `tea` first on
PATH, so nothing reaches the network and the developer's own store is never in
the blast radius.
"""
import json
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
sys.path.insert(0, SYNC_SCRIPTS)
sys.path.insert(0, ISSUE_SCRIPTS)
import _gitea # noqa: E402
import issue # noqa: E402
# A `tea` that answers without a network: an empty list for every GET (so the
# repository looks like it has no labels yet) and a created object for every
# write. It also records its own argv, which is how a test can tell that the
# payload file the script wrote is the one the call actually referenced.
FAKE_TEA = '''#!%s
import json, os, sys
with open(os.path.join(os.environ["TEA_CALL_LOG"], "calls.txt"), "a") as f:
f.write("\\t".join(sys.argv[1:]) + "\\n")
sys.stdout.write(json.dumps({"id": 1, "name": "created"})
if "-X" in sys.argv else "[]")
'''
class FakeRepo(object):
"""A self-contained repository with no store and no tmp/ at all."""
def __init__(self):
self._tmp = tempfile.TemporaryDirectory()
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
# own cwd would otherwise disagree with the path we handed it.
self.root = os.path.realpath(self._tmp.name)
os.makedirs(os.path.join(self.root, ".git")) # the repo marker
skip = shutil.ignore_patterns("__pycache__")
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
# the transport resolves the login pin through skills/auth/scripts
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
os.makedirs(self.path("sub", "deeper"))
# the login pin the transport insists on, local to this fixture
os.makedirs(self.path(".claude"))
with open(self.path(".claude", "settings.local.json"), "w") as f:
json.dump({"env": {"GITEA_LOGIN": "fixture/user"}}, f)
self.bin = self.path("fakebin")
os.makedirs(self.bin)
tea = os.path.join(self.bin, "tea")
with open(tea, "w") as f:
f.write(FAKE_TEA % sys.executable)
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
def cleanup(self):
self._tmp.cleanup()
def path(self, *parts):
return os.path.join(self.root, *parts)
@property
def store(self):
return self.path("tmp", "issues")
@property
def payloads(self):
return self.path("tmp", "payload")
def script(self, layer, name):
return self.path("skills", layer, "scripts", name)
def run(self, script, *args, **kw):
env = dict(os.environ)
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
env["PATH"] = self.bin + os.pathsep + env["PATH"]
env["TEA_CALL_LOG"] = self.root
p = subprocess.run([sys.executable, script] + list(args),
cwd=kw.pop("cwd", self.root), env=env,
capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
def calls(self):
p = os.path.join(self.root, "calls.txt")
if not os.path.isfile(p):
return []
with open(p) as f:
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
# --------------------------------------------------------------------------
# resolution
# --------------------------------------------------------------------------
class TestPayloadRoot(unittest.TestCase):
def test_root_is_absolute_and_repo_anchored(self):
self.assertTrue(os.path.isabs(_gitea.PAYLOAD_ROOT), _gitea.PAYLOAD_ROOT)
self.assertEqual(_gitea.PAYLOAD_ROOT, os.path.join(REPO, "tmp", "payload"))
def test_it_is_not_the_issue_store_and_not_inside_one(self):
"""The acceptance criterion, as a path fact: a request body is not
store content, so it may not live in a store or under one."""
self.assertNotEqual(_gitea.PAYLOAD_ROOT, issue.ISSUE_ROOT)
self.assertFalse(_gitea.PAYLOAD_ROOT.startswith(issue.ISSUE_ROOT + os.sep))
self.assertFalse(issue.ISSUE_ROOT.startswith(_gitea.PAYLOAD_ROOT + os.sep))
def test_the_name_says_what_it_holds(self):
"""Named so the distinction is visible: a top-level directory called
`payload`, not a dotdir hiding among an issue's files."""
self.assertEqual(os.path.basename(_gitea.PAYLOAD_ROOT), "payload")
self.assertFalse(os.path.basename(_gitea.PAYLOAD_ROOT).startswith("."))
def test_gitignore_covers_it(self):
"""The rule is `tmp/` is ignored, not which file says so: this plugin
lives under `plugins/` in a marketplace repo, and git reads every
.gitignore on the way up. So walk up the same way git does."""
ignored = set()
d = REPO
while True:
p = os.path.join(d, ".gitignore")
if os.path.isfile(p):
with open(p) as f:
ignored |= {line.strip() for line in f}
parent = os.path.dirname(d)
if parent == d or os.path.isdir(os.path.join(d, ".git")):
break
d = parent
self.assertEqual(_gitea.PAYLOAD_PARTS[0], "tmp")
self.assertIn("tmp/", ignored,
"the payload directory is not covered by .gitignore")
def test_resolution_is_anchored_on_the_module_not_on_cwd(self):
repo = FakeRepo()
self.addCleanup(repo.cleanup)
self.assertEqual(_gitea.payload_root(repo.path("sub", "deeper")),
repo.payloads)
# --------------------------------------------------------------------------
# the bug: a label bootstrap that materialized the store
# --------------------------------------------------------------------------
class TestLabelsTouchesNoStore(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
def bootstrap(self, *args, **kw):
rc, out, err = self.repo.run(self.repo.script("sync", "labels.py"),
"--repo", "fixture/repo", *args, **kw)
self.assertEqual(rc, 0, "labels.py failed:\n%s%s" % (out, err))
return out, err
def test_bootstrap_creates_no_store(self):
"""The reproduction from the report, run for real: no tmp/issues, and
no complaint about one either."""
out, _ = self.bootstrap()
self.assertIn("created", out)
self.assertFalse(os.path.exists(self.repo.store),
"labels.py created the issue store")
def test_bootstrap_writes_its_payloads_to_the_payload_root(self):
self.bootstrap()
self.assertTrue(os.path.isdir(self.repo.payloads),
"no payload directory: %s" % self.repo.payloads)
written = os.listdir(self.repo.payloads)
self.assertIn("label-type-bug.json", written)
for name in written:
self.assertTrue(name.startswith("label-"), name)
# and the file named on the command line is the one that was written
sent = [a[a.index("-d") + 1][1:] for a in self.repo.calls() if "-d" in a]
self.assertTrue(sent)
for path in sent:
self.assertEqual(os.path.dirname(path), self.repo.payloads)
self.assertTrue(os.path.isfile(path), path)
def test_the_payload_is_the_request_body(self):
self.bootstrap()
with open(os.path.join(self.repo.payloads, "label-type-bug.json")) as f:
body = json.load(f)
self.assertEqual(body.get("name"), "type/bug")
self.assertTrue(body.get("color"))
def test_a_dry_run_writes_nothing_at_all(self):
out, _ = self.bootstrap("--dry-run")
self.assertIn("nothing was written", out)
self.assertFalse(os.path.exists(self.repo.path("tmp")),
"a dry run left something behind in tmp/")
def test_the_directory_does_not_follow_cwd(self):
"""Run from a subdirectory: still one payload root, at the repo root.
A cwd-relative directory is how the store ended up with a second copy
of itself, and this one is resolved the same way to avoid the same
class of bug."""
self.bootstrap(cwd=self.repo.path("sub", "deeper"))
self.assertTrue(os.path.isdir(self.repo.payloads))
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")))
self.assertFalse(os.path.exists(self.repo.store))
# --------------------------------------------------------------------------
# one place, every caller
# --------------------------------------------------------------------------
class TestOnePlaceForEveryCaller(unittest.TestCase):
def hits(self, needle, skip_transport=False):
"""Every `layer/script.py:line` mentioning `needle`."""
out = []
d = SYNC_SCRIPTS
layer = os.path.basename(os.path.dirname(d))
for name in sorted(os.listdir(d)):
if not name.endswith(".py") or (skip_transport and name == "_gitea.py"):
continue
with open(os.path.join(d, name)) as f:
for n, line in enumerate(f, 1):
if needle in line:
out.append("%s/%s:%d" % (layer, name, n))
return out
def test_no_caller_chooses_where_its_payload_goes(self):
"""Whatever the answer is, it has to be the same for all of them —
payload files scattered across the stores of whichever command wrote
them is the state this replaced."""
self.assertEqual(self.hits("out_root"), [],
"a caller still picks a payload directory of its own")
def test_only_the_transport_names_the_directory(self):
self.assertEqual(self.hits("PAYLOAD", skip_transport=True), [],
"the payload directory is named outside the transport")
if __name__ == "__main__":
unittest.main()
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
A pull returns the unit of work: the issue AND what blocks it.
`--deps` used to be opt-in, so `pull.py 42` wrote a file with an empty
`depends:` and `issue_tree.py` drew it as a root with no blockers. The edge was
not lost — it lives in Gitea's native dependency graph — but it was not asked
for, and the body cannot supply it: `map.from_api` writes slugs into the
`## Depends on` prose and never `#N`. Following the graph is now the default.
What is asserted here:
1. **The default fills the graph.** A bare `pull.py <n>` fills `depends:` and
pulls the blocker too, down to `--depth`.
2. **`--no-deps` is the way out, and it is free.** No `depends:`, no recursion,
and not one request beyond the issue itself.
3. **`--deps` still works and means nothing.** Calls written against the old
default keep running and get what they always got.
4. **The cost is one request per stored issue.** The native links are fetched
once and used twice — for `depends:` and for the walk. Never twice.
5. **Filter mode follows blockers out of the selection, deliberately.** A
blocker no filter selected still lands in the store and does not spend
`--limit`; a closed one is dropped like any other closed issue, and so is
the edge to it. An issue the filter dropped costs no link request at all.
The transport is stubbed at `_gitea.api`, as the other suites do it, and the
stub records every call so "how many requests" is an observation. No network,
and no test touches the developer's store: each builds its own in a
`tempfile.TemporaryDirectory()`.
"""
import contextlib
import io
import os
import sys
import tempfile
import unittest
import urllib.parse
from unittest import mock
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
os.path.join(_ROOT, "skills", "issue", "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
import _gitea # noqa: E402
import issue # noqa: E402
import pull # noqa: E402
REPO = "claude-skills/tea"
BASE = "repos/%s" % REPO
BODY = """## Summary
Прозаическое описание задачи.
## Spec
skills/issue/references/format.md
## Acceptance criteria
- [ ] что-нибудь работает
"""
def payload(number, title, state="open"):
return {"number": number, "title": title, "body": BODY, "state": state,
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
"milestone": None, "ref": "main", "updated_at": "2026-08-10T00:00:00Z",
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
"repository": {"full_name": REPO}}
class FakeTracker(object):
"""`tea api` answered from memory, with a native dependency graph.
`listed` is what the list endpoint serves — the filter's selection. `extra`
exists and is fetchable by number but is in no selection, which is how a
blocker outside the filter is modelled. `deps` maps a blocked issue's number
to the numbers that block it, the direction `GET …/dependencies` reads.
"""
def __init__(self, listed=(), extra=(), deps=None):
self.listed = list(listed)
self.issues = {p["number"]: p for p in list(listed) + list(extra)}
self.deps = {int(k): list(v) for k, v in (deps or {}).items()}
self.calls = [] # (method, path), in request order
# -- what the tests read off it ----------------------------------------
def paths(self, suffix):
return [p for m, p in self.calls if p.endswith(suffix)]
def issue_gets(self):
"""`GET …/issues/<n>` — one issue fetched by number."""
return [p for m, p in self.calls
if m == "GET" and p.startswith("%s/issues/" % BASE)
and p.rsplit("/", 1)[1].isdigit()]
# -- the seam ----------------------------------------------------------
def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False):
path, _, qs = endpoint.partition("?")
q = urllib.parse.parse_qs(qs)
self.calls.append((method, path))
if path == "%s/issues" % BASE and method == "GET":
page, per = int(q["page"][0]), int(q["limit"][0])
return self.listed[(page - 1) * per:(page - 1) * per + per]
if path.endswith("/comments"):
return []
if path.endswith("/dependencies") and method == "GET":
n = int(path.split("/issues/")[1].split("/")[0])
return [self.issues[b] for b in self.deps.get(n, []) if b in self.issues]
if path.startswith("%s/issues/" % BASE) and method == "GET":
return self.issues.get(int(path.rsplit("/", 1)[1]))
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
class PullDepsTestCase(unittest.TestCase):
"""A temp store, a fake tracker, no git and no network."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="tea-deps-")
self.addCleanup(self.tmp.cleanup)
self.root = os.path.join(self.tmp.name, "tmp", "issues")
os.makedirs(self.root)
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
p.start()
self.addCleanup(p.stop)
def serve(self, listed=(), extra=(), deps=None):
self.fake = FakeTracker(listed, extra, deps)
p = mock.patch.object(_gitea, "api", self.fake.api)
p.start()
self.addCleanup(p.stop)
return self.fake
def blocked_pair(self):
"""#10 "Second thing" is blocked by #7 "First thing"."""
return self.serve(listed=[payload(10, "Second thing"),
payload(7, "First thing")],
deps={10: [7]})
def run_pull(self, *argv):
out, err = io.StringIO(), io.StringIO()
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(err):
pull.main()
return out.getvalue(), err.getvalue()
def stored(self):
return sorted(issue.all_ids(self.root))
def depends_of(self, id):
return issue.load(self.root, id).depends
# --------------------------------------------------------------------------
# 1. the default fills the graph
# --------------------------------------------------------------------------
class DepsAreTheDefaultTest(PullDepsTestCase):
def test_a_bare_pull_fills_depends(self):
"""The acceptance criterion, and the whole point: no flag, and the file
knows what blocks it."""
self.blocked_pair()
self.run_pull("10")
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
def test_a_bare_pull_stores_the_blocker(self):
"""`depends:` pointing at a file that is not there would be worse than
an empty one — the blocker comes with it."""
self.blocked_pair()
self.run_pull("10")
self.assertIn("first-thing", self.stored())
def test_the_walk_is_recursive(self):
"""A blocker's blocker is context too, down to --depth (default 3)."""
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
deps={1: [2], 2: [3], 3: [4], 4: [5]})
self.run_pull("1")
self.assertEqual(self.stored(), ["thing-1", "thing-2", "thing-3", "thing-4"],
"the default depth of 3 was not what was walked")
def test_depth_bounds_the_walk(self):
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
deps={1: [2], 2: [3], 3: [4], 4: [5]})
self.run_pull("1", "--depth", "1")
self.assertEqual(self.stored(), ["thing-1", "thing-2"])
def test_the_graph_hint_is_printed_when_there_is_a_graph(self):
self.blocked_pair()
out, _ = self.run_pull("10")
self.assertIn("issue_tree.py", out)
# --------------------------------------------------------------------------
# 2. --no-deps is the way out, and it is free
# --------------------------------------------------------------------------
class NoDepsOptsOutTest(PullDepsTestCase):
def test_no_deps_leaves_depends_empty(self):
self.blocked_pair()
self.run_pull("10", "--no-deps")
self.assertEqual(self.depends_of("second-thing"), [])
def test_no_deps_does_not_pull_the_blocker(self):
self.blocked_pair()
self.run_pull("10", "--no-deps")
self.assertEqual(self.stored(), ["second-thing"])
def test_no_deps_spends_no_extra_request(self):
"""The other half of the criterion: not the links, not the blocker.
One issue asked for, one request made."""
self.blocked_pair()
self.run_pull("10", "--no-deps")
self.assertEqual(self.fake.paths("/dependencies"), [])
self.assertEqual(self.fake.issue_gets(), ["%s/issues/10" % BASE])
def test_no_deps_prints_no_graph_hint(self):
self.blocked_pair()
out, _ = self.run_pull("10", "--no-deps")
self.assertNotIn("issue_tree.py", out)
# --------------------------------------------------------------------------
# 3. --deps is still accepted, and means nothing
# --------------------------------------------------------------------------
class DepsFlagIsANoOpTest(PullDepsTestCase):
def test_the_flag_is_still_accepted(self):
"""Existing calls and the /tea:sync command tables must not break."""
self.blocked_pair()
self.run_pull("10", "--deps")
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
def test_it_changes_nothing_about_the_run(self):
self.blocked_pair()
self.run_pull("10", "--deps")
with_flag = (self.stored(), self.depends_of("second-thing"),
list(self.fake.calls))
self.setUp()
self.blocked_pair()
self.run_pull("10")
self.assertEqual((self.stored(), self.depends_of("second-thing"),
list(self.fake.calls)), with_flag)
# --------------------------------------------------------------------------
# 4. one request per stored issue
# --------------------------------------------------------------------------
class TheCostIsOneRequestPerIssueTest(PullDepsTestCase):
def test_the_links_are_fetched_once_per_issue(self):
"""They fill `depends:` AND steer the walk; fetching them twice is
double the price the docstring quotes."""
self.blocked_pair()
self.run_pull("10")
self.assertEqual(self.fake.paths("/dependencies"),
["%s/issues/10/dependencies" % BASE,
"%s/issues/7/dependencies" % BASE])
def test_a_bulk_pull_costs_one_per_issue(self):
"""The number the docstring quotes: one list request, then one link
request per issue that lands in the store."""
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 21)])
self.run_pull("-q", "x")
self.assertEqual(len(self.fake.paths("/dependencies")), 20)
self.assertEqual(len(self.fake.paths("/issues")), 1)
def test_a_cached_issue_costs_its_links_and_nothing_else(self):
"""--cached stops the body and the thread, not the graph: a cached
issue's blockers can be missing from disk even when it is not."""
self.blocked_pair()
self.run_pull("10", "--no-deps") # only #10 on disk
self.fake.calls = []
self.run_pull("10", "--cached")
self.assertEqual(self.fake.paths("/dependencies"),
["%s/issues/10/dependencies" % BASE,
"%s/issues/7/dependencies" % BASE])
self.assertIn("first-thing", self.stored())
# --------------------------------------------------------------------------
# 5. filter mode follows blockers out of the selection
# --------------------------------------------------------------------------
class FilterModeFollowsOutwardTest(PullDepsTestCase):
def test_a_blocker_outside_the_filter_lands_in_the_store(self):
"""Documented as deliberate: a blocker is followed because a stored
issue named it, not because the filter selected it."""
self.serve(listed=[payload(1, "Selected thing")],
extra=[payload(99, "Outside thing")],
deps={1: [99]})
self.run_pull("-q", "x")
self.assertEqual(self.stored(), ["outside-thing", "selected-thing"])
self.assertEqual(self.depends_of("selected-thing"), ["outside-thing"])
def test_a_blocker_does_not_spend_the_limit(self):
"""--limit counts the selection's writes; the graph is not part of the
selection, so the store can legitimately hold more than N."""
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 5)],
extra=[payload(100 + n, "Blocker %d" % n) for n in range(1, 5)],
deps={n: [100 + n] for n in range(1, 5)})
self.run_pull("-q", "x", "--limit", "2")
self.assertEqual(self.stored(),
["blocker-1", "blocker-2", "thing-1", "thing-2"])
def test_a_closed_blocker_is_dropped_with_the_edge_to_it(self):
"""The documented exception. Closed is not a unit of work, so filter
mode drops it like any other closed issue — and `depends:` must not be
left pointing at a file that is not there."""
self.serve(listed=[payload(1, "Selected thing")],
extra=[payload(99, "Closed blocker", state="closed")],
deps={1: [99]})
self.run_pull("-q", "x")
self.assertEqual(self.stored(), ["selected-thing"])
self.assertEqual(self.depends_of("selected-thing"), [])
def test_a_closed_blocker_is_stored_in_key_mode(self):
"""An address is not a bulk read: `pull.py 1` has no closed rule."""
self.serve(listed=[payload(1, "Selected thing")],
extra=[payload(99, "Closed blocker", state="closed")],
deps={1: [99]})
self.run_pull("1")
self.assertEqual(self.stored(), ["closed-blocker", "selected-thing"])
def test_a_dropped_closed_issue_costs_no_link_request(self):
"""Nothing was stored for it, so there is no unit of work to complete
— and its own blockers are not dragged in behind it."""
self.serve(listed=[payload(1, "Closed thing", state="closed"),
payload(2, "Open thing")],
extra=[payload(50, "Blocker of the closed one")],
deps={1: [50]})
self.run_pull("-q", "x", "--state", "all")
self.assertEqual(self.fake.paths("/dependencies"),
["%s/issues/2/dependencies" % BASE])
self.assertEqual(self.stored(), ["open-thing"])
if __name__ == "__main__":
unittest.main()
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""
`pull.py --limit N` bounds the WRITE, not the selection.
The bug this file exists to keep dead: the limit used to cut the list of
payloads before pull.py dropped the closed ones, so a milestone whose first
issues are closed spent the budget on issues that never reached disk —
`--limit 20` wrote twelve, and the docstring promised twenty.
What is asserted, in the order the fix has to hold it:
1. **The count is of files.** N issues under the filter that would be stored →
exactly N files, however many closed ones were enumerated on the way.
2. **Pagination serves the budget.** More pages are requested while the budget
is unfilled, and the page after the one that fills it is never requested.
3. **The scan is bounded.** A filter that matches almost only closed issues
stops after `_gitea.PAGE_SLACK` times the ideal page count, says so, and
returns short — it does not walk the tracker.
4. **`remote.py` is unchanged.** Its `--limit` still caps the listing, closed
issues included, because it writes nothing there is a limit for.
The transport is stubbed at `_gitea.api`, the way the other suites do it, and
the stub serves `page=` / `limit=` itself so the request pattern is a real
observation and not an assumption. No network, and no test writes to the
developer's store: each one builds its own in a `tempfile.TemporaryDirectory()`.
"""
import contextlib
import io
import os
import sys
import tempfile
import unittest
import urllib.parse
from unittest import mock
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
os.path.join(_ROOT, "skills", "issue", "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
import pull # noqa: E402
import remote # noqa: E402
REPO = "claude-skills/tea"
BASE = "repos/%s" % REPO
BODY = """## Summary
Прозаическое описание задачи.
## Spec
skills/issue/references/format.md
## Acceptance criteria
- [ ] что-нибудь работает
"""
def payload(number, state="open", title=None, comments=0):
return {"number": number, "title": title or "Issue number %d" % number,
"body": BODY, "state": state, "comments": comments,
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
"ref": "main", "updated_at": "2026-08-10T00:00:00Z",
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
"repository": {"full_name": REPO}}
def alternating(count, first="closed"):
"""`count` issues, every other one closed. The shape of the bug report:
closed issues sitting in front of the open ones, in page order."""
other = "open" if first == "closed" else "closed"
return [payload(n, first if n % 2 else other) for n in range(1, count + 1)]
class FakeTracker(object):
"""`tea api` answered from a list, with real pagination.
It slices on the `page=` and `limit=` it was given rather than ignoring
them, so "which pages were requested" is something the test can read off
`self.list_pages` instead of inferring."""
def __init__(self, payloads):
self.payloads = list(payloads)
self.list_pages = [] # (page, per_page), in request order
def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False):
path, _, qs = endpoint.partition("?")
q = urllib.parse.parse_qs(qs)
if path == "%s/issues" % BASE and method == "GET":
page, per = int(q["page"][0]), int(q["limit"][0])
self.list_pages.append((page, per))
return self.payloads[(page - 1) * per:(page - 1) * per + per]
if path.endswith("/comments"):
return []
if path.endswith("/dependencies"):
return []
if "/issues/" in path and method == "GET":
n = int(path.rsplit("/", 1)[1])
for p in self.payloads:
if p["number"] == n:
return p
return None
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
class PullLimitTestCase(unittest.TestCase):
"""A temp store, a fake tracker, no git and no network."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="tea-limit-")
self.addCleanup(self.tmp.cleanup)
self.root = os.path.join(self.tmp.name, "tmp", "issues")
os.makedirs(self.root)
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
p.start()
self.addCleanup(p.stop)
# -- runners -----------------------------------------------------------
def serve(self, payloads):
self.fake = FakeTracker(payloads)
p = mock.patch.object(_gitea, "api", self.fake.api)
p.start()
self.addCleanup(p.stop)
return self.fake
def run_pull(self, *argv):
return self._run(pull, "pull.py", argv)
def run_remote(self, *argv):
return self._run(remote, "remote.py", argv)
def _run(self, mod, name, argv):
out, err = io.StringIO(), io.StringIO()
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(err):
mod.main()
return out.getvalue(), err.getvalue()
# -- assertions --------------------------------------------------------
def stored(self):
return sorted(issue.all_ids(self.root))
def assertStoredCount(self, n, why=""):
got = self.stored()
self.assertEqual(len(got), n, "%d issue(s) in the store, wanted %d%s: %s"
% (len(got), n, why and "" + why, got))
# --------------------------------------------------------------------------
# 1. the count is of files
# --------------------------------------------------------------------------
class LimitCountsWritesTest(PullLimitTestCase):
def test_closed_issues_do_not_spend_the_budget(self):
"""The regression. Half the selection is closed and stands in front of
the open ones; the limit still buys ten files."""
self.serve(alternating(40))
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertStoredCount(10)
def test_only_open_issues_landed(self):
self.serve(alternating(40))
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
for id in self.stored():
self.assertEqual(issue.load(self.root, id).state, "open")
def test_the_dropped_ones_are_still_reported(self):
"""Enumerated-and-dropped is not silence: the closed ones seen on the
pages that were fetched are counted on stderr."""
self.serve(alternating(40))
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertIn("closed issue(s) enumerated, not stored", err)
def test_a_closed_issue_already_in_the_store_spends_it(self):
"""It is refreshed rather than dropped — that is a write, so it counts.
The limit is on what the store holds when the run ends, and this issue
is in it."""
kept = issue.Issue(id="already-here", title="Already here", body=BODY,
labels=["type/task"], origin="gitea",
extra={"gitea": gmap.remote_key(REPO, 1)})
issue.save(self.root, kept)
_gitea.save_map(self.root, {gmap.remote_key(REPO, 1): "already-here"})
self.serve(alternating(40)) # #1 is closed, and is on disk
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertStoredCount(10)
self.assertEqual(issue.load(self.root, "already-here").state, "closed",
"a stored issue must learn it was closed")
def test_state_closed_writes_closed_ones(self):
"""Nothing above may leak into the mode where closed IS the selection."""
self.serve([payload(n, "closed") for n in range(1, 21)])
self.run_pull("-q", "x", "--state", "closed", "--limit", "6")
self.assertStoredCount(6)
# --------------------------------------------------------------------------
# 2. pagination serves the budget
# --------------------------------------------------------------------------
class PaginationFollowsTheBudgetTest(PullLimitTestCase):
def test_more_pages_are_fetched_until_the_budget_is_full(self):
"""One page of ten holds five open issues, so ten files cost two."""
self.serve(alternating(40))
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertStoredCount(10)
self.assertEqual([p for p, _ in self.fake.list_pages], [1, 2])
def test_the_page_after_the_last_needed_one_is_never_requested(self):
"""The budget fills inside page 2; page 3 exists and must not be asked
for. Bounding the write must not become fetching the whole repo."""
self.serve(alternating(200))
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertEqual(len(self.fake.list_pages), 2,
"extra pages requested: %r" % (self.fake.list_pages,))
def test_an_unfiltered_selection_still_costs_one_page(self):
"""Nothing is dropped, so nothing changes: the old arithmetic holds."""
self.serve([payload(n) for n in range(1, 60)])
self.run_pull("-q", "x", "--limit", "10")
self.assertStoredCount(10)
self.assertEqual(len(self.fake.list_pages), 1)
def test_running_out_of_pages_gives_a_short_answer(self):
"""Six issues, three of them open, `--limit 10`: three files, no crash,
and no page beyond the last."""
self.serve(alternating(6))
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertStoredCount(3)
self.assertEqual(len(self.fake.list_pages), 1)
# --------------------------------------------------------------------------
# 3. the scan is bounded
# --------------------------------------------------------------------------
class ScanIsBoundedTest(PullLimitTestCase):
def test_a_selection_of_only_closed_issues_stops_at_the_page_budget(self):
self.serve([payload(n, "closed") for n in range(1, 501)])
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertStoredCount(0)
self.assertEqual(len(self.fake.list_pages), _gitea.PAGE_SLACK,
"the scan walked past its budget: %r" % (self.fake.list_pages,))
self.assertIn("short of --limit", err)
def test_a_full_budget_does_not_warn(self):
"""The warning means "there may be more"; it must not fire on a run
that got everything it asked for."""
self.serve(alternating(40))
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertNotIn("short of --limit", err)
def test_a_selection_that_ran_out_does_not_warn(self):
"""Six issues in the repo and the server said so — that is an answer,
not a truncation."""
self.serve(alternating(6))
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
self.assertNotIn("short of --limit", err)
# --------------------------------------------------------------------------
# 4. remote.py is the deliberate exception
# --------------------------------------------------------------------------
class RemoteListingIsUnchangedTest(PullLimitTestCase):
def test_the_listing_limit_still_counts_lines_not_writes(self):
"""remote.py writes nothing, so there is no write to bound: ten lines
out, closed ones among them, one request."""
self.serve(alternating(40))
out, _ = self.run_remote("-q", "x", "--state", "all", "--limit", "10")
numbered = [l for l in out.splitlines() if l.startswith("#")]
self.assertEqual(len(numbered), 10)
self.assertTrue(any("closed" in l for l in numbered),
"a listing that hides closed issues is not a listing")
self.assertEqual(len(self.fake.list_pages), 1)
def test_it_leaves_the_store_alone(self):
self.serve(alternating(40))
self.run_remote("-q", "x", "--state", "all", "--limit", "10")
self.assertStoredCount(0, "discovery wrote to the store")
# --------------------------------------------------------------------------
# the transport on its own
# --------------------------------------------------------------------------
class ListIssuesKeepTest(PullLimitTestCase):
"""`_gitea.list_issues` without a caller in front of it — the counting rule
is the transport's, and it is testable without a store."""
def list(self, payloads, **kw):
self.serve(payloads)
return _gitea.list_issues("test-login", BASE, state="all", **kw)
def test_without_keep_the_limit_caps_the_selection(self):
got, _ = self.list(alternating(40), limit=10)
self.assertEqual(len(got), 10)
def test_with_keep_the_limit_caps_the_kept(self):
got, _ = self.list(alternating(40), limit=10,
keep=lambda p: p["state"] == "open")
self.assertEqual(len([p for p in got if p["state"] == "open"]), 10)
def test_the_rejected_ones_come_back_too(self):
"""They were enumerated. The caller reports them; the transport does
not get to throw away what it did not count."""
got, _ = self.list(alternating(40), limit=10,
keep=lambda p: p["state"] == "open")
self.assertTrue([p for p in got if p["state"] == "closed"])
def test_a_limit_below_one_is_refused(self):
"""The page arithmetic divides by the page size, and a limit of zero
used to make that a traceback. It is a usage error, so it reads like
one."""
with self.assertRaises(SystemExit):
self.list(alternating(4), limit=0)
def test_pull_requests_never_count(self):
"""`matches` drops them, so they cannot spend the budget either."""
mixed = []
for n in range(1, 41):
p = payload(n)
if n % 2:
p["pull_request"] = {"merged": False}
mixed.append(p)
got, _ = self.list(mixed, limit=10, keep=lambda p: True)
self.assertEqual(len(got), 10)
self.assertFalse([p for p in got if p.get("pull_request")])
if __name__ == "__main__":
unittest.main()
@@ -97,7 +97,7 @@ class FakeGitea(object):
# -- the seam ---------------------------------------------------------- # -- the seam ----------------------------------------------------------
def api(self, login, endpoint, method="GET", payload=None, def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False): payload_name=None, allow_fail=False):
self.calls.append((method, endpoint, payload)) self.calls.append((method, endpoint, payload))
path = endpoint.split("?")[0] path = endpoint.split("?")[0]
@@ -218,7 +218,7 @@ class AddDependencyTest(unittest.TestCase):
return {"number": 102} return {"number": 102}
with mock.patch.object(_gitea, "api", fake_api): with mock.patch.object(_gitea, "api", fake_api):
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101, out_root=None) ok = _gitea.add_dependency("l", BASE, 102, REPO, 101)
self.assertTrue(ok) self.assertTrue(ok)
method, endpoint, payload = calls[0] method, endpoint, payload = calls[0]
@@ -24,6 +24,7 @@ import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts") ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts") SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
sys.path.insert(0, ISSUE_SCRIPTS) sys.path.insert(0, ISSUE_SCRIPTS)
import issue # noqa: E402 import issue # noqa: E402
@@ -110,6 +111,8 @@ class FakeRepo(object):
skip = shutil.ignore_patterns("__pycache__") skip = shutil.ignore_patterns("__pycache__")
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip) shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip) shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
# the transport resolves the login pin through skills/auth/scripts
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
os.makedirs(self.path("sub", "deeper")) os.makedirs(self.path("sub", "deeper"))
if with_store: if with_store:
@@ -397,9 +400,10 @@ class TestSyncLayerAgrees(unittest.TestCase):
"""Both layers agree by construction, not by coincidence: no script """Both layers agree by construction, not by coincidence: no script
spells the default out for itself.""" spells the default out for itself."""
for layer, names in (("issue", ("issue_new.py", "issue_check.py", for layer, names in (("issue", ("issue_new.py", "issue_check.py",
"issue_tree.py", "issue_index.py")), "issue_tree.py", "issue_index.py",
"issue_evict.py")),
("sync", ("pull.py", "push.py", "remote.py", ("sync", ("pull.py", "push.py", "remote.py",
"comment.py"))): "comment.py", "evict.py"))):
for name in names: for name in names:
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f: with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
src = f.read() src = f.read()
-88
View File
@@ -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.
-173
View File
@@ -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`.
-523
View File
@@ -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
-159
View File
@@ -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())
-87
View File
@@ -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())
-88
View File
@@ -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())
-119
View File
@@ -1,119 +0,0 @@
---
name: wiki
description: Move wiki pages between a local space and Gitea — fetch a page and everything under it as a local cache, publish a page tree with an update message, list what the wiki holds. Load when the user asks to read/fetch a wiki page, cache a wiki subtree for a discussion, or publish artifacts to the wiki. Organizing artifacts into a page tree (titles, ordering, the index) is /tea:page and needs no network.
---
# /tea:wiki — the bridge between a local space and a Gitea wiki
One job: translate between `tmp/wiki/<space>/` and Gitea's wiki JSON, and carry
the result over the wire. Everything about **what a page tree is** — titles,
ordering, paths, the index — belongs to `/tea:page` and is imported from there,
never redefined here.
Transport is `tea api` through `skills/sync/scripts/_gitea.py`: the same login
pin, the same pagination, the same payload files. There is no second transport.
## The wiki is flat, and that is the whole design
Gitea's wiki is a list of pages, not a tree. Nesting exists only inside a
title, as `/`, and Gitea escapes that title into a filename by rules that are
its own:
| title | `sub_url` |
|---|---|
| `Abstract Issue` | `Abstract-Issue` |
| `zz-probe/child` | `zz-probe%2Fchild.-` |
| `Simple Chains/Parked/Chain decisions — DC` | `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC` |
**`sub_url` is the identity and is never constructed.** It is read back from
the API and stored in the manifest. Building one by hand that is almost right
does not fail loudly — it creates a second page and abandons the first.
**Never commit a subdirectory into the wiki's git repository.** A real
`folder/page.md` is invisible to the API and to the web UI. It is a ghost file.
Do not clone the wiki repo to work in; use these scripts.
## Scripts
In `<skill-base-dir>/scripts/`.
| Script | What it does |
|---|---|
| `wiki_ls.py [--prefix T]` | what the wiki actually holds — titles, `sub_url`, last commit. One call, no bodies |
| `wiki_pull.py [--prefix T] [--space S]` | fetch a page and everything under it into a local space |
| `wiki_push.py -m MSG [--prefix T] [PATH…]` | publish; create what is new, update what changed, skip what is not |
| `wikimap.py` | md ↔ wiki JSON, pure — not a command |
## Fetching a subtree as a cache
"A page and its children" is a prefix test on the title, run against one
listing call, followed by one GET per page. There is no tree endpoint and no
bulk-body endpoint.
```bash
python3 scripts/wiki_ls.py --prefix "Simple Chains" # what is there
python3 scripts/wiki_pull.py --prefix "Simple Chains" # cache it locally
```
A pull **overwrites** the local body — a fetch, not a merge. `synced` tells you
how old your copy is; re-pull when it matters. Nothing tracks drift.
The space defaults to the repo's own `owner/repo`, so a pull with no flags
caches this repo's whole wiki into `tmp/wiki/<owner>/<repo>/`.
## Publishing
```bash
python3 scripts/wiki_push.py -m "Import the simple-chains discussion" --dry-run
python3 scripts/wiki_push.py -m "Import the simple-chains discussion"
```
`-m` is required and is the wiki commit message — the only record of why a page
changed, and it shows up in `wiki/revisions/<sub_url>`. One operation, one
message.
Change detection is a hash: a page whose file matches `pushed` is skipped.
A page with no `sub_url` is created; one with a `sub_url` is edited in place,
using the title **from the manifest** — sending a different title to the edit
endpoint is a rename and leaves nothing at the old address.
Selection, narrowest first: positional `PATH`-or-`TITLE` arguments (matched
exactly), then `--prefix`, then the whole space.
**Pushing is additive.** A page deleted locally is not deleted in the wiki.
Removing a published page is an explicit act — the web UI, or
`tea api --login "$GITEA_LOGIN" -X DELETE repos/{owner}/{repo}/wiki/page/<sub_url>`.
## Order of operations for a fresh tree
The index links published pages by `sub_url`, which does not exist until the
first push. So:
```bash
python3 ../page/scripts/page_import.py --from DIR --prefix "Simple Chains"
python3 scripts/wiki_push.py -m "Import the simple-chains discussion"
python3 ../page/scripts/page_index.py --prefix "Simple Chains" # now with real links
python3 scripts/wiki_push.py -m "Index"
```
## Linking an issue to a page
An issue's `wiki:` field holds page **titles**, not URLs — a title is a name for
a document and stays in the domain; the URL is bookkeeping. `wiki_ls.py
--titles` prints them one per line, which is what to paste.
## Endpoints, for when a script is not enough
Reach for `/tea:use` and `tea api` directly only for what has no script — a
delete, or a page's history.
| | |
|---|---|
| list | `GET repos/{owner}/{repo}/wiki/pages` |
| read | `GET repos/{owner}/{repo}/wiki/page/{sub_url}` |
| create | `POST repos/{owner}/{repo}/wiki/new``{title, content_base64, message}` |
| edit | `PATCH repos/{owner}/{repo}/wiki/page/{sub_url}` — same body |
| delete | `DELETE repos/{owner}/{repo}/wiki/page/{sub_url}` |
| history | `GET repos/{owner}/{repo}/wiki/revisions/{sub_url}` |
The `tea` CLI has no wiki subcommand. `tea api` is the only route.
-77
View File
@@ -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())
-127
View File
@@ -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())
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
"""
wiki_push.py — publish a local space to a wiki.
wiki_push.py -m "Import the simple-chains discussion"
wiki_push.py --prefix "Simple Chains/Ideas" -m "Rework B-04"
wiki_push.py -m "Fix the send-timing table" Simple-Chains/Ideas/Send-timing.md
Every page in the selection is compared against `pushed` — the hash of what was
last published — and only the ones that differ are sent. That is the whole of
change detection: no timestamps, no drift model.
A page with no `sub_url` is created; a page with one is edited in place. The
title comes from the manifest, never re-derived from the file, because sending
a different title to the edit endpoint is a RENAME and leaves nothing behind at
the old address.
Pushing is additive. A page deleted locally is NOT deleted in the wiki — the
manifest simply stops mentioning it. Removing a published page is an explicit
act; do it in the web UI or with a DELETE through /tea:use.
Usage:
wiki_push.py -m MESSAGE [--space SPACE] [--repo owner/repo]
[--prefix TITLE] [--dry-run] [--out DIR] [PATH-or-TITLE ...]
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
sys.path.insert(0, os.path.join(_HERE, "..", "..", "page", "scripts"))
import _gitea # noqa: E402
import page # noqa: E402
import wikimap # noqa: E402
def select(manifest, prefix, targets):
"""The pages to consider, in tree order.
A positional argument matches a manifest path or a title, exactly. Exact
because a near-miss that silently selects nothing is indistinguishable from
a clean no-op run, and the operator finds out only when the page never
appears."""
pages = (page.children_of(manifest, prefix) if prefix
else page.sorted_pages(manifest))
if not targets:
return pages, []
want, chosen, hit = set(targets), [], set()
for p, e in pages:
if p in want or e.get("title") in want:
chosen.append((p, e))
hit.add(p if p in want else e.get("title"))
return chosen, sorted(want - hit)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("targets", nargs="*", metavar="PATH-or-TITLE")
ap.add_argument("-m", "--message", required=True,
help="wiki commit message for this push")
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
ap.add_argument("--space", help="local space (default: the owner/repo slug)")
ap.add_argument("--prefix", default="", help="only titles at or under this")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
login = _gitea.require_login()
base = _gitea.repo_base(a.repo)
slug = _gitea.repo_slug(login, a.repo)
space = a.space or slug
root = a.out or page.WIKI_ROOT
space_dir = page.space_root(space, root)
if not os.path.isdir(space_dir):
_gitea.die("no such space: %s (looked in %s). Import or pull first."
% (space, space_dir))
manifest = page.load_manifest(space, root)
if not manifest["pages"]:
_gitea.die("space %s has no pages in its manifest" % space)
chosen, missing = select(manifest, a.prefix, a.targets)
for t in missing:
_gitea.warn("not in the manifest: %s" % t)
if not chosen:
_gitea.die("nothing selected")
created = updated = skipped = 0
for relpath, e in chosen:
title = e.get("title")
full = os.path.join(space_dir, relpath)
if not title:
_gitea.warn("%s has no title in the manifest; skipped" % relpath)
continue
if not os.path.isfile(full):
_gitea.warn("%s is in the manifest but not on disk; skipped" % relpath)
continue
with open(full, encoding="utf-8") as f:
text = f.read()
h = page.body_hash(text)
if e.get("sub_url") and h == e.get("pushed"):
skipped += 1
continue
verb = "create" if not e.get("sub_url") else "update"
print("%-7s %-44s %s" % (verb, relpath, title))
if a.dry_run:
created += verb == "create"
updated += verb == "update"
continue
if verb == "create":
payload = wikimap.new_payload(title, text, a.message)
got = _gitea.api(login, "%s/wiki/new" % base, method="POST",
payload=payload, payload_name="wiki-new",
out_root=space_dir)
else:
payload = wikimap.edit_payload(title, text, a.message)
got = _gitea.api(login, wikimap.page_endpoint(base, e["sub_url"]),
method="PATCH", payload=payload,
payload_name="wiki-edit", out_root=space_dir)
if not isinstance(got, dict) or not got.get("sub_url"):
_gitea.warn("%s: no page returned; the manifest is unchanged for it"
% title)
continue
# sub_url comes back from Gitea and is stored as given. It is the only
# address this page has, and it is not something we could have computed.
e.update(wikimap.from_payload(got))
e["synced"] = _gitea.now_iso()
e["pushed"] = h
manifest["pages"][relpath] = e
created += verb == "create"
updated += verb == "update"
if a.dry_run:
print("\ndry run — %d to create, %d to update, %d unchanged"
% (created, updated, skipped))
return 0
page.save_manifest(manifest, root)
print("\n%d created, %d updated, %d unchanged -> %s wiki"
% (created, updated, skipped, slug))
return 0
if __name__ == "__main__":
sys.exit(main())
-124
View File
@@ -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 + "/")
-523
View File
@@ -1,523 +0,0 @@
#!/usr/bin/env python3
"""
How a directory of markdown becomes a page tree, and that the tree survives a
round trip through the wiki layer's bookkeeping.
python3 -m unittest discover -s tests -v
Stdlib unittest, no third-party anything. `skills/*/scripts/` are not packages,
so the modules under test are imported by path.
Nothing here touches tmp/wiki/. The subprocess cases build a throwaway
repository in a temp directory — a `.git` marker, a copy of both script layers,
a directory of fixture artifacts — and run the real scripts inside it. That is
the only honest way to test behaviour that depends on where a script is run
from, and it keeps the developer's own cache out of the blast radius.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PAGE_SCRIPTS = os.path.join(REPO, "skills", "page", "scripts")
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
sys.path.insert(0, PAGE_SCRIPTS)
sys.path.insert(0, WIKI_SCRIPTS)
import page # noqa: E402
import wikimap # noqa: E402
# The fixture mirrors the shape a real discussion leaves behind: numbered files
# for ordering, a `00-` file standing in for its directory, headings that no
# mechanical rule could derive from the file names.
FIXTURE = {
"handoff.md": "# handoff — notification chains\n\nEntry point.\n",
"ideas/00-intro.md": "# Ideas for chain business requirements\n\nFlat list.\n",
"ideas/02-chain-core.md": "## Chain core\n\n- **B-01.** Something.\n",
"ideas/01-relations.md": "## Relations\n\nHow they relate.\n",
"questions/00-intro.md": "# Questions\n\nOpen questions.\n",
"questions/03-q-01-do-we-know-the-participant.md":
"## Q-01. Do We Know the Chain Participant by Name\n\n**Question.** …\n",
"notes/plain.md": "No heading here, only prose.\n",
}
def build_artifacts(root):
for rel, text in FIXTURE.items():
p = os.path.join(root, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as f:
f.write(text)
return root
# --------------------------------------------------------------------------
# names, titles, order — pure
# --------------------------------------------------------------------------
class TestNames(unittest.TestCase):
def test_order_comes_from_a_numeric_prefix(self):
self.assertEqual(page.order_of("02-chain-core.md"), 2)
self.assertEqual(page.order_of("00-intro.md"), 0)
self.assertIsNone(page.order_of("handoff.md"))
def test_zero_is_an_order_and_not_a_missing_one(self):
"""`00-` means "this is the directory's own page", so the difference
between 0 and None decides where a page lands in the tree."""
self.assertIsNot(page.order_of("00-intro.md"), None)
def test_the_prefix_never_reaches_the_title(self):
self.assertEqual(page.title_from_name("02-chain-core.md"), "Chain core")
def test_only_the_first_letter_is_raised(self):
"""Title-casing would wreck every name that already knows how it is
spelled."""
self.assertEqual(page.title_from_name("sqlc-and-APNs.md"), "Sqlc and APNs")
def test_a_heading_beats_a_file_name(self):
text = "## Q-01. Do We Know the Chain Participant by Name\n"
self.assertEqual(page.title_from_body(text),
"Q-01. Do We Know the Chain Participant by Name")
def test_only_the_first_heading_counts(self):
self.assertEqual(page.title_from_body("# One\n\n## Two\n"), "One")
def test_a_heading_after_prose_is_a_section_not_a_name(self):
self.assertIsNone(page.title_from_body("Prose first.\n\n# Late\n"))
def test_markup_is_stripped_from_a_title(self):
"""A page list does not render markdown, so inline code in a heading is
noise in the name."""
self.assertEqual(page.sanitize_title("Inventory — `P-NN`"),
"Inventory — P-NN")
def test_a_slash_in_a_heading_does_not_invent_hierarchy(self):
self.assertEqual(page.sanitize_title("Send/receive timing"),
"Send-receive timing")
class TestPaths(unittest.TestCase):
def test_a_title_becomes_one_path_component_per_segment(self):
self.assertEqual(page.path_for_title("Simple Chains/Ideas/Chain core"),
os.path.join("Simple-Chains", "Ideas", "Chain-core.md"))
def test_shell_hostile_characters_leave_the_path_but_not_the_title(self):
title = "Simple Chains/Don't send to this one"
self.assertEqual(page.path_for_title(title),
os.path.join("Simple-Chains", "Dont-send-to-this-one.md"))
self.assertIn("'", title)
def test_an_empty_title_still_produces_a_file(self):
self.assertEqual(page.path_for_title(""), "untitled.md")
# --------------------------------------------------------------------------
# importing
# --------------------------------------------------------------------------
class TestPlanImport(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.src = build_artifacts(os.path.join(self.tmp.name, "artifacts"))
self.pages, self.collisions = page.plan_import(self.src, "Simple Chains")
self.titles = {p["title"] for p in self.pages}
def tearDown(self):
self.tmp.cleanup()
def test_nothing_collides(self):
self.assertEqual(self.collisions, [])
def test_an_order_zero_file_becomes_the_directorys_own_page(self):
self.assertIn("Simple Chains/Ideas", self.titles)
def test_that_page_is_named_for_the_directory_not_its_heading(self):
"""`ideas/00-intro.md` opens with "Ideas for chain business
requirements". A child's title must extend its parent's exactly, and no
child would ever be prefixed by that."""
self.assertNotIn("Simple Chains/Ideas for chain business requirements",
self.titles)
def test_every_child_extends_its_parents_title(self):
self.assertIn("Simple Chains/Ideas/Chain core", self.titles)
self.assertIn("Simple Chains/Questions/"
"Q-01. Do We Know the Chain Participant by Name",
self.titles)
def test_a_file_without_a_heading_falls_back_to_its_name(self):
self.assertIn("Simple Chains/Notes/Plain", self.titles)
def test_the_prefix_hangs_everything_under_one_title(self):
self.assertTrue(all(t.startswith("Simple Chains/") for t in self.titles))
def test_numeric_prefixes_order_siblings(self):
ideas = [p for p in self.pages
if p["title"].startswith("Simple Chains/Ideas/")]
self.assertEqual([p["title"].split("/")[-1] for p in ideas],
["Relations", "Chain core"])
def test_a_collision_is_reported_and_not_resolved(self):
"""Two headings that sanitize to one path. Picking a winner is how a
discussion loses a document."""
d = os.path.join(self.tmp.name, "clash")
os.makedirs(d)
for name, heading in (("a.md", "# Send timing"), ("b.md", "# Send/timing")):
with open(os.path.join(d, name), "w") as f:
f.write(heading + "\n")
_, collisions = page.plan_import(d)
self.assertEqual(len(collisions), 1)
# --------------------------------------------------------------------------
# the index
# --------------------------------------------------------------------------
class TestIndex(unittest.TestCase):
def setUp(self):
self.m = page.blank_manifest("s")
for title, order in (("Top", None),
("Top/Ideas", 0),
("Top/Ideas/Relations", 1),
("Top/Ideas/Chain core", 2),
("Top/Zeta", None),
("Top/Parked/Decisions", None)):
self.m["pages"][page.path_for_title(title)] = page.entry(title, order)
def test_nesting_follows_titles_not_manifest_path_order(self):
"""On disk `Top/Zeta.md` sorts before `Top/Ideas/Chain-core.md`; in the
hierarchy Zeta is a child and Chain core a grandchild."""
body = page.render_index(self.m, "Top")
lines = [l for l in body.splitlines() if l.strip().startswith("- ")
or l.strip().startswith(" - ")]
ideas = next(i for i, l in enumerate(lines) if "|Ideas]]" in l)
core = next(i for i, l in enumerate(lines) if "Chain core]]" in l)
zeta = next(i for i, l in enumerate(lines) if "|Zeta]]" in l)
self.assertLess(ideas, core)
self.assertLess(core, zeta)
def test_a_parent_with_no_page_still_holds_its_children(self):
"""Nothing is published at `Top/Parked`; dropping it would hide
Decisions entirely."""
body = page.render_index(self.m, "Top")
self.assertIn("- [[Top/Parked|Parked]]", body)
self.assertIn(" - [[Top/Parked/Decisions|Decisions]]", body)
def test_an_unpublished_page_is_linked_by_wiki_syntax(self):
self.assertIn("[[Top/Ideas|Ideas]]", page.render_index(self.m, "Top"))
def test_a_published_page_is_linked_by_its_sub_url(self):
"""sub_url is the only address Gitea guarantees, and it appears only
after a push — so rebuilding the index after publishing upgrades the
links."""
rel = page.path_for_title("Top/Ideas")
self.m["pages"][rel]["sub_url"] = "Top%2FIdeas"
self.assertIn("- [Ideas](Top%2FIdeas)", page.render_index(self.m, "Top"))
def test_the_prefix_itself_is_not_listed_inside_its_own_index(self):
self.assertNotIn("|Top]]", page.render_index(self.m, "Top"))
# --------------------------------------------------------------------------
# the manifest
# --------------------------------------------------------------------------
class TestManifest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
self.tmp.cleanup()
def test_a_missing_manifest_loads_blank(self):
m = page.load_manifest("a/b", self.tmp.name)
self.assertEqual(m["pages"], {})
def test_wiki_bookkeeping_survives_a_round_trip(self):
"""The domain never reads sub_url, and must never drop it either — a
lost sub_url is a duplicate page on the next push."""
m = page.blank_manifest("a/b")
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X", pushed="deadbeef")
page.save_manifest(m, self.tmp.name)
back = page.load_manifest("a/b", self.tmp.name)
self.assertEqual(back["pages"]["X.md"]["sub_url"], "X")
self.assertEqual(back["pages"]["X.md"]["pushed"], "deadbeef")
self.assertEqual(back["pages"]["X.md"]["order"], 1)
def test_domain_keys_are_written_first(self):
"""The manifest lands in a diff on every sync; a readable one gets
checked."""
m = page.blank_manifest("a/b")
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X")
with open(page.save_manifest(m, self.tmp.name), encoding="utf-8") as f:
raw = f.read()
self.assertLess(raw.index('"title"'), raw.index('"sub_url"'))
def test_children_of_is_a_prefix_test_and_not_a_substring_one(self):
m = page.blank_manifest("s")
for t in ("Top", "Top/A", "Topaz", "Topaz/B"):
m["pages"][page.path_for_title(t)] = page.entry(t)
got = {e["title"] for _, e in page.children_of(m, "Top")}
self.assertEqual(got, {"Top", "Top/A"})
# --------------------------------------------------------------------------
# md <-> wiki JSON
# --------------------------------------------------------------------------
class TestWikiMap(unittest.TestCase):
def test_a_body_survives_encode_and_decode(self):
text = "# Заголовок — DC\n\n- [x] пункт\n"
self.assertEqual(wikimap.decode({"content_base64": wikimap.encode(text)}),
text)
def test_an_empty_page_decodes_to_an_empty_string(self):
"""A page that exists with no body is a real state; the caller writing
a file should not have to tell it from a missing key."""
self.assertEqual(wikimap.decode({}), "")
self.assertEqual(wikimap.decode({"content_base64": None}), "")
def test_from_payload_takes_the_address_gitea_returned(self):
got = wikimap.from_payload({
"title": "A/B", "sub_url": "A%2FB.-", "html_url": "https://x/A%2FB.-",
"last_commit": {"sha": "abc", "author": {"date": "2026-08-10T11:15:39Z"}},
})
self.assertEqual(got["sub_url"], "A%2FB.-")
self.assertEqual(got["sha"], "abc")
self.assertEqual(got["remote-updated"], "2026-08-10T11:15:39Z")
def test_a_sub_url_goes_into_the_endpoint_verbatim(self):
"""Gitea hands it back already escaped; re-encoding it produces a path
that resolves to nothing."""
self.assertEqual(
wikimap.page_endpoint("repos/o/r", "A%2FB.-"),
"repos/o/r/wiki/page/A%2FB.-")
def test_prefix_matching_needs_a_separator(self):
self.assertTrue(wikimap.matches_prefix("Top", "Top"))
self.assertTrue(wikimap.matches_prefix("Top/A", "Top"))
self.assertFalse(wikimap.matches_prefix("Topaz", "Top"))
def test_an_empty_prefix_matches_everything(self):
self.assertTrue(wikimap.matches_prefix("anything", ""))
def test_a_payload_carries_the_operators_message(self):
p = wikimap.new_payload("A/B", "body", "why it changed")
self.assertEqual(p["message"], "why it changed")
self.assertEqual(wikimap.decode(p), "body")
# --------------------------------------------------------------------------
# the scripts, in a throwaway repository
# --------------------------------------------------------------------------
class TestImportScript(unittest.TestCase):
"""The real scripts, run as subprocesses inside a scratch repo."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = self.tmp.name
os.makedirs(os.path.join(self.root, ".git"))
for layer in ("page", "wiki"):
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
os.path.join(self.root, "skills", layer, "scripts"),
ignore=shutil.ignore_patterns("__pycache__"))
shutil.copytree(SYNC_SCRIPTS,
os.path.join(self.root, "skills", "sync", "scripts"),
ignore=shutil.ignore_patterns("__pycache__"))
self.src = build_artifacts(os.path.join(self.root, "artifacts"))
self.scripts = os.path.join(self.root, "skills", "page", "scripts")
self.space = os.path.join(self.root, "tmp", "wiki", "s")
def tearDown(self):
self.tmp.cleanup()
def run_script(self, name, *args, cwd=None):
return subprocess.run(
[sys.executable, os.path.join(self.scripts, name)] + list(args),
capture_output=True, text=True, cwd=cwd or self.root)
def manifest(self):
with open(os.path.join(self.space, ".pages.json"), encoding="utf-8") as f:
return json.load(f)
def do_import(self, *extra):
return self.run_script("page_import.py", "--from", self.src,
"--space", "s", "--prefix", "Top", *extra)
def test_dry_run_writes_nothing(self):
r = self.do_import("--dry-run")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertFalse(os.path.exists(self.space))
def test_import_writes_the_tree_and_the_manifest(self):
self.assertEqual(self.do_import().returncode, 0)
self.assertTrue(os.path.isfile(
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
self.assertIn("Top/Ideas/Chain core",
{e["title"] for e in self.manifest()["pages"].values()})
def test_creating_a_space_is_announced(self):
"""Nothing creates a store as a silent side effect of a write — that is
how a typo in --space makes a second one nobody notices."""
self.assertIn("created space", self.do_import().stderr)
def test_the_cache_is_found_from_a_subdirectory(self):
"""The anchor is the script's own location, not cwd. A `cd` outlives
the command that ran it."""
self.do_import()
deep = os.path.join(self.src, "ideas")
r = self.run_script("page_ls.py", "--space", "s", cwd=deep)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("Chain core", r.stdout)
def test_a_reimport_keeps_the_title_and_the_wiki_bookkeeping(self):
self.do_import()
m = self.manifest()
rel = "Top/Ideas/Chain-core.md"
m["pages"][rel]["sub_url"] = "Top%2FIdeas%2FChain-core"
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
# The heading changes. Without the manifest that would rename a
# published page, which does not rename it — it publishes a second one.
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
f.write("## A completely different heading\n\nchanged\n")
self.do_import()
after = self.manifest()["pages"][rel]
self.assertEqual(after["title"], "Top/Ideas/Chain core")
self.assertEqual(after["sub_url"], "Top%2FIdeas%2FChain-core")
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
self.assertIn("A completely different heading", f.read())
def test_retitle_moves_the_page_and_keeps_its_address(self):
"""A retitle changes the path, so the entry has to be found by source.
Found by path it would look new, and the next push would publish a
duplicate beside the page it was meant to rename."""
self.do_import()
m = self.manifest()
m["pages"]["Top/Ideas/Chain-core.md"]["sub_url"] = "Top%2FIdeas%2FChain-core"
m["pages"]["Top/Ideas/Chain-core.md"]["pushed"] = "deadbeef"
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
f.write("## Chain core, renamed\n")
self.do_import("--retitle")
pages = self.manifest()["pages"]
self.assertNotIn("Top/Ideas/Chain-core.md", pages)
moved = pages["Top/Ideas/Chain-core-renamed.md"]
self.assertEqual(moved["title"], "Top/Ideas/Chain core, renamed")
self.assertEqual(moved["sub_url"], "Top%2FIdeas%2FChain-core")
self.assertFalse(os.path.exists(
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
def test_a_rename_makes_the_next_push_send_the_page(self):
"""The body can be byte-identical after a rename, and push decides by
body hash alone — so a stale `pushed` would skip the rename forever."""
self.do_import()
m = self.manifest()
rel = "Top/Ideas/Chain-core.md"
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
body = f.read()
m["pages"][rel]["sub_url"] = "x"
m["pages"][rel]["pushed"] = __import__("hashlib").sha1(
body.encode()).hexdigest()
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
src = os.path.join(self.src, "ideas", "02-chain-core.md")
with open(src, encoding="utf-8") as f:
text = f.read()
with open(src, "w") as f:
f.write(text.replace("## Chain core", "## Chain core renamed"))
self.do_import("--retitle")
moved = self.manifest()["pages"]["Top/Ideas/Chain-core-renamed.md"]
self.assertNotIn("pushed", moved)
def test_ls_reports_an_unpublished_page_as_local(self):
self.do_import()
r = self.run_script("page_ls.py", "--space", "s")
self.assertIn("local", r.stdout)
self.assertNotIn("synced", r.stdout)
def test_ls_distinguishes_a_missing_space_from_an_empty_one(self):
r = self.run_script("page_ls.py", "--space", "nope")
self.assertNotEqual(r.returncode, 0)
self.assertIn("no such space", r.stderr)
def test_index_is_written_as_an_ordinary_page(self):
self.do_import()
r = self.run_script("page_index.py", "--space", "s", "--prefix", "Top")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("Top.md", self.manifest()["pages"])
with open(os.path.join(self.space, "Top.md"), encoding="utf-8") as f:
body = f.read()
self.assertIn("- [[Top/Ideas|Ideas]]", body)
self.assertIn(" - [[Top/Ideas/Chain core|Chain core]]", body)
# --------------------------------------------------------------------------
# the layering rule, mechanically
# --------------------------------------------------------------------------
class TestLayering(unittest.TestCase):
def test_the_page_layer_is_stdlib_only(self):
"""skills/page must keep working with skills/wiki deleted — so no
transport, and above all no subprocess, in the domain layer."""
imported = set()
for name in sorted(os.listdir(PAGE_SCRIPTS)):
if not name.endswith(".py"):
continue
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
for line in f:
if line.startswith(("import ", "from ")):
imported.add(line.split()[1].split(".")[0])
foreign = imported - {"page"} - sys.stdlib_module_names
self.assertEqual(foreign, set(),
"non-stdlib import in the page layer: %s"
% ", ".join(sorted(foreign)))
self.assertNotIn("subprocess", imported)
def test_the_page_layer_never_mentions_a_tracker(self):
"""A sub_url, a login, an HTTP verb in skills/page means the concept is
in the wrong layer."""
banned = ("tea api", "_gitea", "GITEA_LOGIN", "content_base64")
for name in sorted(os.listdir(PAGE_SCRIPTS)):
if not name.endswith(".py"):
continue
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
body = f.read()
for word in banned:
self.assertNotIn(word, body,
"%s mentions %r" % (name, word))
def test_wikimap_is_pure(self):
"""The translation layer holds no transport and no I/O: give it a
payload, get a page; give it a page, get a request body. Checked on the
imports, not on the prose — the docstring names the things it refuses
to do."""
with open(os.path.join(WIKI_SCRIPTS, "wikimap.py")) as f:
imported = {line.split()[1].split(".")[0] for line in f
if line.startswith(("import ", "from "))}
self.assertEqual(imported, {"base64"},
"wikimap.py imports more than the translation needs")
if __name__ == "__main__":
unittest.main()