Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83f73c5cea | |||
| 23f78beafb | |||
| 5a8bd1c299 | |||
| 81119a3bd9 | |||
| f97ac952f7 | |||
| 493a787940 | |||
| 7ab967bfaa | |||
| edb2f5a627 | |||
| 62027db76c | |||
| 9479babfe9 | |||
| e330a11e8f | |||
| bf0936526d | |||
| f7cffd7c48 | |||
| e6b4cf773c | |||
| 2f82b501bd |
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"name": "tea",
|
||||
"name": "claude-skills",
|
||||
"owner": {
|
||||
"name": "naudachu"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,175 +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.
|
||||
|
||||
## 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 |
|
||||
| `/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)
|
||||
One repository, one marketplace, several plugins. Register it once and install
|
||||
whichever pieces you want; each plugin is independent and carries its own
|
||||
manifest, docs, and tests.
|
||||
|
||||
## 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 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
|
||||
/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
|
||||
right skill automatically. `/tea:auth` is only needed for the tracker side;
|
||||
`/tea:issue` works without any login at all.
|
||||
Working from a local clone? Point at the directory instead:
|
||||
|
||||
## 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. The hook and the scripts look it up the same way — one search order, in `skills/auth/scripts/pin.py`.
|
||||
Then install what you need:
|
||||
|
||||
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`
|
||||
```
|
||||
/plugin install tea@claude-skills
|
||||
/plugin install tdl@claude-skills
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
## Layout
|
||||
|
||||
```
|
||||
.claude-plugin/
|
||||
plugin.json plugin manifest
|
||||
marketplace.json marketplace catalog (makes `/plugin install` work)
|
||||
agents/
|
||||
tea-runner.md subagent (Haiku) that executes the scripts
|
||||
hooks/
|
||||
hooks.json registers the PreToolUse hook
|
||||
tea-guard.sh the guard (Python 3, no deps)
|
||||
skills/
|
||||
auth/SKILL.md /tea:auth skill
|
||||
issue/ /tea:issue — the domain layer, 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_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
|
||||
marketplace.json the catalog — one entry per plugin, source is a
|
||||
path into plugins/
|
||||
plugins/
|
||||
tea/
|
||||
.claude-plugin/plugin.json
|
||||
agents/ hooks/ skills/ tests/
|
||||
README.md AGENTS.md
|
||||
tdl/
|
||||
.claude-plugin/plugin.json
|
||||
skills/
|
||||
```
|
||||
|
||||
## 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
|
||||
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
|
||||
a parser.
|
||||
## Development
|
||||
|
||||
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:
|
||||
`tea` has a test suite; run it from its own directory so the tests resolve
|
||||
their root correctly:
|
||||
|
||||
- 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.
|
||||
```
|
||||
cd plugins/tea && python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
@@ -1,132 +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. WHERE that file is looked for is not decided
|
||||
here: skills/auth/scripts/pin.py holds the search order, and the sync and wiki
|
||||
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)
|
||||
|
||||
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}"}
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
# 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)
|
||||
|
||||
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.')
|
||||
|
||||
new_cmd = cmd[:m.start(3)] + shlex.quote(login) + cmd[m.end(3):]
|
||||
rewrite(tool_input, new_cmd,
|
||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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(¤t)
|
||||
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(¤t)
|
||||
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`
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -15,18 +15,15 @@
|
||||
|
||||
## Layers
|
||||
|
||||
The hard rule of this repo. Two domains, two bridges, one transport, and
|
||||
The hard rule of this repo. One domain, one bridge, one transport, and
|
||||
knowledge flows one way only:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
skills/page DOMAIN what a page tree is: title <-> path, order, the index
|
||||
▲ offline — no tracker, no network, stdlib imports only
|
||||
│ imports
|
||||
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
||||
_gitea.py tea api, pagination, filters, payloads
|
||||
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
|
||||
transport is _gitea.py — there is no second one
|
||||
│ imports
|
||||
▼
|
||||
skills/auth IDENTITY pin the login the whole tracker side runs under
|
||||
@@ -40,19 +37,16 @@ skills/use REFERENCE tea CLI docs for everything that is not an issue
|
||||
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
||||
```
|
||||
|
||||
A domain never imports its bridge, and the two domains do not import each
|
||||
other: delete `skills/sync` and issues still work, delete `skills/wiki` and page
|
||||
trees still work, delete either domain and the other is untouched. The check is
|
||||
mechanical — every import under a domain's `scripts/` is stdlib, and
|
||||
`subprocess` is not among them:
|
||||
The domain never imports its bridge: delete `skills/sync` and issues still
|
||||
work. The check is mechanical — every import under the domain's `scripts/` is
|
||||
stdlib, and `subprocess` is not among them:
|
||||
|
||||
```bash
|
||||
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
||||
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
|
||||
```
|
||||
|
||||
If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
||||
`content_base64`) shows up in a domain layer, it is in the wrong place.
|
||||
If a tracker concept (issue number, login, HTTP call, label color) shows up in
|
||||
the domain layer, it is in the wrong place.
|
||||
|
||||
## Repo layout
|
||||
|
||||
@@ -68,6 +62,8 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
||||
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
|
||||
substring, changing exactly one character of the file
|
||||
- `scripts/issue_tree.py` — draw the dependency graph
|
||||
- `scripts/issue_evict.py` — remove closed issues from the store; never an
|
||||
`origin: local` one
|
||||
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
||||
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
||||
@@ -75,22 +71,10 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
|
||||
the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py`
|
||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
||||
- `scripts/close.py` — the state field, both ways; explicit ids only
|
||||
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
|
||||
the domain's `issue_evict.run`
|
||||
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
|
||||
repository; reads the domain taxonomy, never the store
|
||||
- `skills/page` — a discussion's artifacts as a page tree (`/tea:page`),
|
||||
entirely offline
|
||||
- `references/pages.md` — canonical page-tree format; single source of truth
|
||||
- `scripts/page.py` — domain module: title ↔ path, ordering, the manifest,
|
||||
importing a directory of markdown, the index
|
||||
- `scripts/page_import.py` — copy a directory of markdown into a space,
|
||||
titling every file
|
||||
- `scripts/page_index.py` — write the table-of-contents page
|
||||
- `scripts/page_ls.py` — the tree, the titles, one sync-state tag per page
|
||||
- `skills/wiki` — move page trees between a local space and a Gitea wiki
|
||||
(`/tea:wiki`)
|
||||
- `scripts/wikimap.py` — md ↔ Gitea wiki JSON, pure, no I/O
|
||||
- `scripts/wiki_ls.py`, `wiki_pull.py`, `wiki_push.py` — transport is
|
||||
`skills/sync/scripts/_gitea.py`
|
||||
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
||||
(`/tea:use`); `references/tea/` holds the command docs
|
||||
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
||||
@@ -121,7 +105,7 @@ working tree of any linked worktree** met on the way, reached by reading
|
||||
`gitdir:` out of a `.git` *file* and following `commondir`.
|
||||
|
||||
**The pin is not resolved from `__file__`, and that asymmetry with
|
||||
`issue.store_root`/`page.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.**
|
||||
`issue.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.**
|
||||
Where an installation keeps its files is a fact about the installation; whose
|
||||
login a project runs under is a fact about the project. A plugin installed
|
||||
outside any repository and pointed at somebody else's tree must not answer the
|
||||
@@ -145,11 +129,10 @@ stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
|
||||
packages, so a test that needs the domain module imports it with
|
||||
`sys.path.insert`.
|
||||
|
||||
**A test never touches `tmp/issues/`, `tmp/wiki/` or `tmp/payload/`.** Anything
|
||||
that needs a store builds a throwaway repository in a
|
||||
`tempfile.TemporaryDirectory()` — a `.git` marker, a copy of the script layers,
|
||||
fixture issues or artifacts — and runs the real scripts inside it as
|
||||
subprocesses. That is the only way to test behavior that depends on where a
|
||||
**A test never touches `tmp/issues/` or `tmp/payload/`.** Anything that needs a
|
||||
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
||||
`.git` marker, a copy of the script layers, fixture issues — and runs the real
|
||||
scripts inside it as subprocesses. That is the only way to test behavior that depends on where a
|
||||
script is run from, and it keeps the developer's own store out of the blast
|
||||
radius.
|
||||
|
||||
@@ -200,48 +183,42 @@ line so plain grep works without a parser.
|
||||
- `.remote.json` is therefore no longer "an index over the files": it is the
|
||||
local number → slug ledger, its entries outlive the files they name, and
|
||||
nothing prunes them. It is still recoverable — from the markers in Gitea, not
|
||||
from the files.
|
||||
from the files. **Eviction does not prune it either**, for the same reason a
|
||||
push does not: an evicted issue is in exactly the state a pushed one is.
|
||||
- **A closed issue is evicted, not archived.** `issue_evict.py` removes
|
||||
`<id>.md` and every sidecar under that slug for anything that is `state:
|
||||
closed` **and** carries an `origin:` naming a tracker, then rebuilds
|
||||
`INDEX.md`. `--dry-run` prints and writes nothing. **`origin: local` is never
|
||||
evicted, in any state, not even when named on the command line** — that file
|
||||
*is* the issue and nothing can fetch it back.
|
||||
- **Eviction lives in the domain** (`skills/issue/scripts/issue_evict.py`),
|
||||
because its two inputs — `state:` and `origin:` — are domain fields and the
|
||||
answer is already on disk. No network, no login, no `tea`.
|
||||
`skills/sync/scripts/evict.py` is the bridge form: it refreshes `state:` from
|
||||
the tracker first (a local `state:` is only as fresh as the last pull) and then
|
||||
calls `issue_evict.run`. One implementation of "what may be evicted", in the
|
||||
layer that owns the fields it reads. Same gate as push, one step earlier: a
|
||||
failed or unconfirmed tracker answer evicts nothing at all.
|
||||
- **Pull by number fetches an issue in any state — a number is a number.** An
|
||||
address is not a query: `pull.py 42` puts a closed issue on disk exactly as it
|
||||
always has, and so does `#42`, `owner/repo#42`, or its URL. Only filter mode
|
||||
(`--milestone`, `--label`, `-q`) leaves closed issues out. Eviction does not
|
||||
revoke this: a closed issue pulled after a cleanup lands on disk again, and
|
||||
that is the tracker answering what it was asked, not a regression. Evict it
|
||||
again when you are done with it.
|
||||
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
|
||||
issue comes back at all.
|
||||
- No drift tracking, and now nothing to track: there is no second copy to
|
||||
diverge from. `synced:` tells you how old your working copy is.
|
||||
|
||||
## Local wiki cache
|
||||
|
||||
`tmp/wiki/<space>/` (gitignored) holds page trees — a discussion's artifacts,
|
||||
organized. Same stance as the issue store, resolved the same way from
|
||||
`page.py`'s own location, with the same `--out` rule.
|
||||
|
||||
- Identity is the **title**, and `/` inside it is the only hierarchy there is.
|
||||
The Gitea wiki is flat: it escapes a title into one filename by rules of its
|
||||
own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`).
|
||||
- **`sub_url` is Gitea's address for a page and is never constructed.** It is
|
||||
read back from the API and stored in `.pages.json`. One built by hand that is
|
||||
almost right creates a second page instead of editing the first.
|
||||
- **Never commit a subdirectory into a wiki's git repository.** Gitea does not
|
||||
see it — the page exists on disk and nowhere in the API or the UI. Do not
|
||||
clone the wiki repo to work in; use the scripts.
|
||||
- A title is a decision, not a derivation. A re-import replaces bodies and
|
||||
keeps titles, so editing a heading cannot silently rename a published page.
|
||||
`--retitle` opts in, and the rename reaches the wiki on the next push.
|
||||
- A page with no `sub_url` has never been published — a complete state, the way
|
||||
`origin: local` is for an issue. **The parallel stops at the push**: a pushed
|
||||
page stays on disk, a pushed issue does not.
|
||||
- Change detection is one hash (`pushed`). Pulling overwrites; pushing is
|
||||
additive and never deletes — the one place the two domains deliberately
|
||||
disagree, because a page tree is worked on locally and an issue is not.
|
||||
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
|
||||
`_gitea.py`.
|
||||
|
||||
## Request payloads
|
||||
|
||||
`tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
|
||||
one file per named request, kept after the call for a retry or a post-mortem.
|
||||
It is **not a store and holds nobody's only copy** — deleting it costs nothing.
|
||||
|
||||
- One directory for every caller — sync and wiki both — resolved from
|
||||
`_gitea.py`'s own location, so which command wrote a body does not change
|
||||
where it landed. `_gitea.api` takes no directory argument; that it once did
|
||||
- One directory for every caller, resolved from `_gitea.py`'s own location, so
|
||||
which command wrote a body does not change where it landed. `_gitea.api` takes no directory argument; that it once did
|
||||
is exactly how a label bootstrap came to create `tmp/issues/`.
|
||||
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
|
||||
or a run with nothing to send leaves no directory behind.
|
||||
@@ -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.
|
||||
@@ -30,11 +30,9 @@ to fill the gap yourself.
|
||||
Load the skill, do not remember the flags:
|
||||
|
||||
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `close.py`, `remote.py`,
|
||||
`labels.py`
|
||||
`labels.py`, `evict.py`
|
||||
- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`,
|
||||
`issue_new.py`, `issue_ac.py`
|
||||
- `/tea:wiki` — `wiki_ls.py`, `wiki_pull.py`, `wiki_push.py`
|
||||
- `/tea:page` — `page_import.py`, `page_index.py`, `page_ls.py`
|
||||
`issue_new.py`, `issue_ac.py`, `issue_evict.py`
|
||||
|
||||
Invoke `Skill` with the one that owns the task at the start, and use the command
|
||||
table it gives you verbatim. The skill is the single source of
|
||||
@@ -45,8 +43,7 @@ instead of trying it.
|
||||
## Hard rules
|
||||
|
||||
1. **No raw `tea`.** Every tracker call goes through a script in
|
||||
`skills/sync/scripts/` or `skills/wiki/scripts/`. The one exception is a
|
||||
diagnostic the skill itself
|
||||
`skills/sync/scripts/`. The one exception is a diagnostic the skill itself
|
||||
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
|
||||
the `tea-guard` hook substitutes the pinned login. Never name a login.
|
||||
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
|
||||
@@ -56,25 +53,29 @@ instead of trying it.
|
||||
only the items the caller named, by the number or the substring the caller
|
||||
gave. Whether a criterion is actually met is a judgement about content, and
|
||||
content is never yours.
|
||||
3. **Push only what you were told to push.** `push.py` and `wiki_push.py`
|
||||
publish to a tracker other people read, **and `push.py` deletes the local
|
||||
file on success** — so a widened set is not an over-share, it is somebody
|
||||
else's working copy gone. Run them with the ids, titles, or filter the
|
||||
caller named. Never widen the set, never run a bare `push.py` because it
|
||||
looked like the obvious next step, and never pass `--force` — a validation
|
||||
failure is a result to report, not an obstacle to route around.
|
||||
`wiki_push.py` needs `-m`; use the caller's words, never your own summary.
|
||||
Report the number and URL `push.py` printed; that is now the only address
|
||||
the issue has.
|
||||
3. **Push only what you were told to push.** `push.py` publishes to a tracker
|
||||
other people read, **and it deletes the local file on success** — so a
|
||||
widened set is not an over-share, it is somebody else's working copy gone.
|
||||
Run it with the ids, titles, or filter the caller named. Never widen the
|
||||
set, never run a bare `push.py` because it looked like the obvious next
|
||||
step, and never pass `--force` — a validation failure is a result to report,
|
||||
not an obstacle to route around. Report the number and URL `push.py`
|
||||
printed; that is now the only address the issue has.
|
||||
4. **Close only the ids the caller named.** Closing is a script now
|
||||
(`close.py`), so it is yours to run — under the same discipline as push: the
|
||||
ids the caller named, and no others. Never widen the set, never infer that
|
||||
an issue is finished because its checkboxes are ticked or its branch is
|
||||
merged; whether work is done is a judgement about content, and content is
|
||||
never yours. `--reopen` is the same rule backwards. **Deleting and
|
||||
retitling stay forbidden** on both sides — on the wiki that means no
|
||||
`--retitle`, since renaming a published page abandons the old one. The one
|
||||
deletion you may cause is push's own, on the issue you were told to push.
|
||||
never yours. `--reopen` is the same rule backwards. **Retitling stays
|
||||
forbidden**, and deleting anything on a tracker is never yours either.
|
||||
|
||||
Two local deletions are allowed, both only when the caller asked for them:
|
||||
push's own, on the issue you were told to push, and eviction
|
||||
(`issue_evict.py` / `evict.py`) of closed issues. Run eviction with
|
||||
`--dry-run` first and report what it named; never widen the set past what
|
||||
the caller said. It refuses to touch an `origin: local` issue by itself —
|
||||
that is the script's guarantee, not your judgement, and it is not a reason
|
||||
to point it at a store nobody asked you to clean.
|
||||
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
||||
permute flags looking for one that works.
|
||||
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
|
||||
Executable
+280
@@ -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()
|
||||
@@ -51,7 +51,7 @@ So:
|
||||
## Where the pin is looked for
|
||||
|
||||
One search order, written once in `scripts/pin.py` and imported by both the
|
||||
`tea-guard` hook and the sync/wiki transport — they cannot disagree about a
|
||||
`tea-guard` hook and the sync transport — they cannot disagree about a
|
||||
directory, and a test asserts neither keeps a copy of the walk.
|
||||
|
||||
`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's
|
||||
@@ -3,7 +3,7 @@
|
||||
pin.py — where the operator's Gitea login pin is, and how it is found.
|
||||
|
||||
**The search order lives here and nowhere else.** The `tea-guard` hook imports
|
||||
this module; so does the transport every sync and wiki script runs on. Two
|
||||
this module; so does the transport every sync script runs on. Two
|
||||
copies of the order is exactly how a git worktree came to have a working hook
|
||||
and a dead transport in the same directory: `tea` resolved the login, the
|
||||
scripts said "no login pinned", and the error told the operator to pin what was
|
||||
@@ -38,6 +38,7 @@ All offline, all in `<skill-base-dir>/scripts/`.
|
||||
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
|
||||
| `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
|
||||
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
|
||||
| `issue_evict.py [id…] [--dry-run]` | remove closed issues from the store; **never** an `origin: local` one |
|
||||
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
|
||||
| `issue.py` | the domain module the others import — not a command |
|
||||
|
||||
@@ -207,6 +208,50 @@ on `tmp/issues/<id>.md`, and this layer does not know the difference. Getting
|
||||
the rewritten body into the tracker is a separate decision — `push.py --update`
|
||||
in `/tea:sync` — and is no part of this.
|
||||
|
||||
## Evicting closed issues
|
||||
|
||||
The store is a working set, not an archive. A closed issue is not a unit of
|
||||
work any more, and one command takes it out — no `rm`, no rebuilding `INDEX.md`
|
||||
by hand:
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_evict.py --dry-run # what would go
|
||||
python3 <skill-base-dir>/scripts/issue_evict.py # every closed one
|
||||
python3 <skill-base-dir>/scripts/issue_evict.py old-thing # just this one
|
||||
```
|
||||
|
||||
Two conditions, both read off the file, and the second one is the whole safety
|
||||
argument:
|
||||
|
||||
| `state:` | `origin:` | what eviction does |
|
||||
|---|---|---|
|
||||
| `closed` | a tracker | removes `<id>.md` and every sidecar under that slug |
|
||||
| `closed` | `local` | **keeps it, always**, and says why |
|
||||
| `open` | anything | keeps it |
|
||||
|
||||
**`origin: local` is never evicted, in any state, not even when you name it on
|
||||
the command line.** That file *is* the issue; there is no copy to fetch back.
|
||||
Only a file whose own metadata says the work lives somewhere else may go — the
|
||||
same trade `push.py` makes when it drops a file the tracker just confirmed.
|
||||
|
||||
- `--dry-run` prints what would go and writes nothing at all, `INDEX.md`
|
||||
included.
|
||||
- `INDEX.md` is rebuilt afterwards, so the table and the directory agree. It is
|
||||
rebuilt only when something was actually removed.
|
||||
- `.remote.json` is **not** pruned, deliberately: it is the number → slug
|
||||
ledger, and its entries are supposed to outlive the files they name (that is
|
||||
what makes `pull.py <n>` land on the same slug after a push). An evicted issue
|
||||
is in exactly the state a pushed one is.
|
||||
- **This is not a one-off migration.** `pull.py <n>` fetches an issue in any
|
||||
state — a number is an address, not a query — so a closed issue pulled after
|
||||
an eviction lands on disk again. Not a regression: evict it again when you are
|
||||
done reading it.
|
||||
|
||||
This command is offline and decides from `state:` in the file, which is only as
|
||||
fresh as the last pull. To have the tracker's answer instead — an issue closed
|
||||
in the web UI five minutes ago — use `/tea:sync`'s `evict.py`, which refreshes
|
||||
`state:` first and then calls exactly this decision.
|
||||
|
||||
## Dependency graph
|
||||
|
||||
`depends:` is the authoritative edge list; the body's `## Depends on` section
|
||||
@@ -3,8 +3,7 @@
|
||||
Canonical format for every issue in this project, whether it ever reaches a
|
||||
tracker or not. Designed to be unambiguous for both humans and LLMs: fixed
|
||||
English section headers in a fixed order, verifiable acceptance criteria, one
|
||||
issue = one deliverable. Source spec: the project wiki
|
||||
([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
|
||||
issue = one deliverable.
|
||||
|
||||
Nothing here depends on Gitea. How these files are mapped onto a tracker is the
|
||||
sync layer's business — see `/tea:sync`.
|
||||
@@ -42,7 +41,6 @@ labels: [type/task, tech/sql]
|
||||
assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
wiki: [Simple Chains/Ideas/Chain core]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
@@ -64,7 +62,6 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
| `assignees` | domain | logins; may be empty |
|
||||
| `milestone` | domain | title, or `none` |
|
||||
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
||||
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them |
|
||||
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
||||
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
|
||||
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
|
||||
@@ -82,19 +79,24 @@ represent a local issue and a synced one without a second format.
|
||||
leaves this machine is valid and finished work; pushing it is optional and
|
||||
nothing here treats it as a draft.
|
||||
|
||||
It is not a *permanent* state, and this is the one place where the file's fate
|
||||
depends on it:
|
||||
It is not a *permanent* state, and it is what the file's fate depends on:
|
||||
|
||||
| `origin:` | what the file is | what a push does to it |
|
||||
|---|---|---|
|
||||
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file |
|
||||
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file |
|
||||
| `origin:` | what the file is | what a push does to it | what eviction does to it |
|
||||
|---|---|---|---|
|
||||
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
|
||||
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
|
||||
|
||||
**A successful push deletes `tmp/issues/<id>.md`** (and `<id>.comments.md`), on
|
||||
create and on `--update` alike. What is in the store is what has not left this
|
||||
machine; everything else is fetched again when it is needed. The rule, its
|
||||
safety conditions, and how the slug survives are `/tea:sync`'s to state.
|
||||
|
||||
**A closed issue is evicted from the store** by `issue_evict.py` — same trade,
|
||||
one condition more: the work is done *and* it exists somewhere else. An
|
||||
`origin: local` issue is never evicted, because there is nowhere to fetch it
|
||||
back from. The store is a working set, not an archive; `pull.py <n>` fetches a
|
||||
closed issue again whenever it is wanted.
|
||||
|
||||
The `id` never changes across that round trip, which is why `depends:` in other
|
||||
issues keeps working. That is the format's promise; the mechanism is not.
|
||||
|
||||
@@ -113,7 +113,8 @@ ISSUE_ROOT = store_root()
|
||||
|
||||
# Domain-owned metadata, in render order. Foreign keys render after these,
|
||||
# sorted, so the sync layer can add fields without touching this list.
|
||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"]
|
||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
|
||||
"origin"]
|
||||
LIST_KEYS = {"labels", "assignees", "depends"}
|
||||
STATES = ("open", "closed")
|
||||
|
||||
@@ -247,8 +248,8 @@ class Issue(object):
|
||||
"""One unit of work. `extra` holds metadata this layer does not own."""
|
||||
|
||||
def __init__(self, id="", title="", body="", state="open", labels=None,
|
||||
assignees=None, milestone="", depends=None, origin=LOCAL,
|
||||
extra=None):
|
||||
assignees=None, milestone="", depends=None,
|
||||
origin=LOCAL, extra=None):
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.body = body
|
||||
@@ -635,6 +636,35 @@ def all_ids(root):
|
||||
and "." not in f[:-3])
|
||||
|
||||
|
||||
def slug_files(root, id):
|
||||
"""Every file the store holds under one slug — the issue and its sidecars.
|
||||
|
||||
`<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
||||
companion another layer parked there (`<id>.comments.md` is the one that
|
||||
exists today). `all_ids` already refuses to read those as issues because a
|
||||
slug has no dot in it; this is the same rule read the other way round.
|
||||
|
||||
Which is how the domain can remove an issue *completely* without learning
|
||||
what any of those companions are: it does not need to know that a comment
|
||||
thread exists to know that a file named after this issue belongs to it and
|
||||
goes when it goes. The issue's own file comes first — it is the headline of
|
||||
any receipt printed from this list.
|
||||
|
||||
A missing store is an empty list, not an error: nothing is there to remove.
|
||||
"""
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
own, sidecars = [], []
|
||||
for name in sorted(os.listdir(root)):
|
||||
if not name.startswith("%s." % id):
|
||||
continue
|
||||
p = os.path.join(root, name)
|
||||
if not os.path.isfile(p):
|
||||
continue
|
||||
(own if name == "%s.md" % id else sidecars).append(p)
|
||||
return own + sidecars
|
||||
|
||||
|
||||
def load(root, id):
|
||||
with open(path_of(root, id)) as f:
|
||||
return Issue.from_text(f.read(), id=id)
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_evict.py — closed issues leave the store. Offline.
|
||||
|
||||
issue_evict.py every closed issue that is not origin: local
|
||||
issue_evict.py old-thing … only these
|
||||
issue_evict.py --dry-run print what would go; touch nothing
|
||||
|
||||
The store is a working set, not an archive. A closed issue is not a unit of
|
||||
work any more, and `pull.py` has kept new ones out of filter mode for a while —
|
||||
but the files already on disk were nobody's job, so the only way to remove one
|
||||
was `rm` past every script, followed by rebuilding `INDEX.md` by hand. This is
|
||||
that job.
|
||||
|
||||
WHAT IS EVICTED, and it is two conditions, both read off the file:
|
||||
|
||||
state: closed the work is done
|
||||
origin: <tracker> the work is somewhere else too
|
||||
|
||||
TWO CONDITIONS, AND THE SECOND ONE IS THE WHOLE SAFETY ARGUMENT. `origin:
|
||||
local` means this file IS the issue — there is no other copy and deleting it
|
||||
deletes the work. It is therefore never evicted, in any state, not even when
|
||||
named explicitly on the command line: a closed local issue is reported and
|
||||
kept. The only files that go are ones whose own metadata says the work can be
|
||||
fetched back (`pull.py <n>`), which is the same trade `push.py` makes when it
|
||||
drops a file the tracker has just confirmed.
|
||||
|
||||
That parallel is exact except for where the confirmation comes from. Push has
|
||||
to ask Gitea, because it is Gitea that just changed. Eviction asks the file,
|
||||
because `state:` and `origin:` are domain fields and the answer is already in
|
||||
the store — which is why this command lives in the domain layer and needs no
|
||||
network, no login, and no `tea`. See `skills/sync/scripts/evict.py` for the
|
||||
variant that refreshes `state:` from the tracker first; it makes the deletion
|
||||
decision by calling `run()` below, so there is exactly one implementation of
|
||||
"what may be evicted" and it is this one.
|
||||
|
||||
NOT A ONE-OFF MIGRATION. `pull.py <n>` fetches an issue in any state — a number
|
||||
is an address, not a query — so a closed issue pulled after an eviction lands on
|
||||
disk again. That is the tracker being asked a direct question, not a regression,
|
||||
and the answer is to evict again when you are done with it.
|
||||
|
||||
`.remote.json` is deliberately NOT pruned. It is the local number -> slug
|
||||
ledger, its entries outlive the files they name (that is what makes `pull.py
|
||||
<n>` land on the same slug after a push deleted the file), and an evicted issue
|
||||
is in exactly that state. `INDEX.md` is rebuilt, because it *is* a view of the
|
||||
directory.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
|
||||
CLOSED = "closed"
|
||||
|
||||
# Why an issue was kept, in the receipt. `LOCAL_REASON` is the one that matters:
|
||||
# it is printed whether or not the issue was named, because "this closed thing
|
||||
# is still here" needs an answer every time.
|
||||
LOCAL_REASON = "origin: %s — this file IS the issue" % issue.LOCAL
|
||||
|
||||
|
||||
def classify(issues, ids=None):
|
||||
"""Split the store into (evict, protected, still_open).
|
||||
|
||||
Pure — it reads the loaded issues and decides; nothing here touches disk.
|
||||
|
||||
evict closed, and lives in a tracker too: safe to remove
|
||||
protected closed, but `origin: local`: the only copy of the work
|
||||
still_open not closed
|
||||
|
||||
`ids` restricts the question to those issues; without it the whole store is
|
||||
considered. A protected issue is returned as such even when it was named
|
||||
explicitly — naming a file does not make deleting it safe.
|
||||
"""
|
||||
chosen = list(ids) if ids else sorted(issues)
|
||||
evict, protected, still_open = [], [], []
|
||||
for id in chosen:
|
||||
iss = issues[id]
|
||||
if iss.state != CLOSED:
|
||||
still_open.append(id)
|
||||
elif iss.is_local:
|
||||
protected.append(id)
|
||||
else:
|
||||
evict.append(id)
|
||||
return evict, protected, still_open
|
||||
|
||||
|
||||
def remove(root, id):
|
||||
"""Delete everything the store holds under one slug; return the paths.
|
||||
|
||||
Deliberately dumb, and for the same reason `push.drop_local` is: it takes an
|
||||
id, not a decision. Whether an issue may go is settled by `classify` before
|
||||
this is reached, so the dangerous half of the operation has no branches in
|
||||
it at all. There is exactly one call site.
|
||||
"""
|
||||
gone = []
|
||||
for p in issue.slug_files(root, id):
|
||||
os.remove(p)
|
||||
gone.append(p)
|
||||
return gone
|
||||
|
||||
|
||||
def run(root, issues, ids=None, dry_run=False, out=None):
|
||||
"""Classify, report, remove, rebuild the index. Returns (gone, kept).
|
||||
|
||||
The one implementation of eviction, called both by `main` below and by the
|
||||
sync layer's `evict.py` — which does nothing to this decision except hand
|
||||
over issues whose `state:` it has just refreshed from the tracker.
|
||||
|
||||
`gone` is {id: [paths]} and is empty on a dry run; `kept` is
|
||||
[(id, why)] for everything considered and not removed.
|
||||
"""
|
||||
out = out or sys.stdout
|
||||
evict, protected, still_open = classify(issues, ids)
|
||||
|
||||
gone, kept = {}, []
|
||||
for id in evict:
|
||||
paths = issue.slug_files(root, id) if dry_run else remove(root, id)
|
||||
if not dry_run:
|
||||
gone[id] = paths
|
||||
out.write("%-11s %s\n" % ("would evict" if dry_run else "evicted", id))
|
||||
for p in paths:
|
||||
out.write(" %s\n" % p)
|
||||
for id in protected:
|
||||
kept.append((id, LOCAL_REASON))
|
||||
out.write("%-11s %s closed, %s\n" % ("kept", id, LOCAL_REASON))
|
||||
# An open issue is the normal case and says nothing worth a line — unless
|
||||
# the operator named it, in which case they are owed the reason.
|
||||
for id in still_open:
|
||||
kept.append((id, "state: %s" % issues[id].state))
|
||||
if ids:
|
||||
out.write("%-11s %s state: %s\n" % ("kept", id, issues[id].state))
|
||||
|
||||
if dry_run:
|
||||
out.write("%d issue(s) would be evicted, %d kept — nothing was touched\n"
|
||||
% (len(evict), len(kept)))
|
||||
return gone, kept
|
||||
|
||||
out.write("%d issue(s) evicted, %d kept\n" % (len(gone), len(kept)))
|
||||
# Only when something actually went: the index is a view of the directory,
|
||||
# and rewriting it after a run that changed nothing is a write nobody asked
|
||||
# for.
|
||||
if gone:
|
||||
path, n = issue_index.build(root)
|
||||
out.write("index: %s — %d issue(s)\n" % (path, n))
|
||||
return gone, kept
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Evict closed issues from the local store (offline)")
|
||||
ap.add_argument("ids", nargs="*",
|
||||
help="issue ids (default: every closed issue in the store)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print what would be removed; touch nothing")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
root = args.out
|
||||
if not issue.store_exists(root):
|
||||
sys.exit("issue_evict.py: store %s does not exist — nothing to evict" % root)
|
||||
|
||||
issues = issue.load_all(root)
|
||||
missing = [i for i in args.ids if i not in issues]
|
||||
if missing:
|
||||
sys.exit("issue_evict.py: no such issue(s) in the store: %s"
|
||||
% ", ".join(missing))
|
||||
|
||||
run(root, issues, args.ids, args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -45,6 +45,7 @@ there is nothing to pin a second time. No pin anywhere → exit with a pointer t
|
||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
|
||||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
|
||||
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
|
||||
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
|
||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
|
||||
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
|
||||
@@ -354,8 +355,11 @@ A milestone must already exist in the repo — push attaches, it does not create
|
||||
|
||||
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
|
||||
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
||||
and writes it back into the issue file; a value already there is never
|
||||
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
|
||||
and sends it up as `ref`; a value already there is never
|
||||
overwritten, neither on create nor on `--update`. Nothing is written back to
|
||||
the issue file — there is no file left to write to, because a successful push
|
||||
deletes it. The branch comes back on disk with the next `pull.py <n>`, from
|
||||
the tracker. On a detached HEAD or outside
|
||||
a git repo no `ref` is sent and a warning names the issues that went up without
|
||||
one. Reading the branch is the only thing these scripts ask git for — they
|
||||
never check out, create, or write anything.
|
||||
@@ -414,6 +418,57 @@ The index is rebuilt when at least one local file changed, so `INDEX.md` never
|
||||
outlives the state it reports. Nothing is deleted here — unlike a push, a close
|
||||
leaves the working copy where it is.
|
||||
|
||||
## Evicting what the tracker says is closed
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/evict.py --dry-run # ask, report, change nothing
|
||||
python3 <skill-base-dir>/scripts/evict.py # and remove them
|
||||
python3 <skill-base-dir>/scripts/evict.py old-thing # just this one
|
||||
```
|
||||
|
||||
Eviction itself belongs to `/tea:issue` (`issue_evict.py`) and is offline: the
|
||||
decision is `state: closed` plus an `origin:` that names a tracker, both read
|
||||
off the file. This script adds one thing in front of it — a `state:` that is not
|
||||
stale — and then calls that same decision. There is one implementation of "what
|
||||
may be evicted" and it is in the domain.
|
||||
|
||||
Why it exists: a local `state:` is only as fresh as the last pull, so an issue
|
||||
closed in the web UI still reads `open` here and the offline command correctly
|
||||
leaves it alone. The workaround was `pull.py 11 12 13 14 15` — which writes the
|
||||
five closed files back to disk before anything can remove them.
|
||||
|
||||
Order of operations, and it is the safety argument:
|
||||
|
||||
1. every candidate's state is fetched — **all** of them, before anything is
|
||||
removed;
|
||||
2. each answer must be an object carrying the number that was asked about and a
|
||||
state the domain recognizes (`evict.confirmed_state`, the counterpart of
|
||||
`push.confirmed_number`);
|
||||
3. only then does the eviction run.
|
||||
|
||||
**A failed call evicts nothing** — not even the candidates whose answers had
|
||||
already arrived, and no refreshed `state:` is written back either. Stricter than
|
||||
push, which deletes as it goes, and free: evictions have no order between them,
|
||||
so there is no reason to start before every answer is in.
|
||||
|
||||
- A **candidate** is an issue carrying a `gitea:` handle. `origin: local` has
|
||||
none, is never asked about, and is never removed. An `origin: gitea` issue
|
||||
whose handle is missing or unparseable cannot be verified — it is reported on
|
||||
stderr and kept.
|
||||
- No `--repo`: the repo comes from each issue's own handle, so a store holding
|
||||
issues from two repos is checked against both.
|
||||
- One GET per candidate. The store is a working set that push keeps small, and a
|
||||
wrong answer here deletes a file — so each issue is asked about by its own
|
||||
address rather than inferred from a list a `--limit` could have truncated.
|
||||
- A state that disagrees with the file is written back, so the store stops lying
|
||||
about the issues that stay too. `--dry-run` makes no writes at all.
|
||||
- `.remote.json` is not pruned; see [How the slug comes
|
||||
back](#how-the-slug-comes-back) — an evicted issue is exactly as findable as a
|
||||
pushed one.
|
||||
- **`pull.py <n>` still fetches a closed issue.** A number is an address, not a
|
||||
query. A closed issue pulled after an eviction is back on disk, and that is
|
||||
the tracker answering the question it was asked, not a regression.
|
||||
|
||||
## What crosses the boundary, and what does not
|
||||
|
||||
| domain | Gitea | note |
|
||||
@@ -48,9 +48,8 @@ PAGE_SLACK = 4
|
||||
# --------------------------------------------------------------------------
|
||||
# where request bodies land
|
||||
# --------------------------------------------------------------------------
|
||||
# Anchored on THIS FILE, like issue.store_root and page.store_root, so every
|
||||
# caller — sync, wiki, whatever comes next — writes to one directory whatever
|
||||
# it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
|
||||
# Anchored on THIS FILE, like issue.store_root, so every caller — sync,
|
||||
# whatever comes next — writes to one directory whatever it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
|
||||
# inside somebody's store, because a scratchpad that looks like store contents
|
||||
# is how this went wrong the first time. `tmp/` is already gitignored.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
evict.py — ask Gitea which stored issues are closed, then evict those.
|
||||
|
||||
evict.py check every synced issue in the store, evict the
|
||||
ones Gitea says are closed
|
||||
evict.py old-thing … only these
|
||||
evict.py --dry-run ask, report, change nothing
|
||||
|
||||
The offline command is `/tea:issue`'s `issue_evict.py`, and it is the one that
|
||||
decides and deletes — this script adds exactly one thing in front of it: a
|
||||
`state:` that is not stale. A local `state:` is only as fresh as the last pull,
|
||||
so an issue closed in the web UI an hour ago still reads `open` here and the
|
||||
offline command will (correctly) leave it alone. That is the gap this closes,
|
||||
and it is the observed workflow: before this existed the operator had to
|
||||
`pull.py 11 12 13 14 15` first, which re-wrote the five closed files onto disk
|
||||
before anything could remove them.
|
||||
|
||||
Order of operations, and it is the whole safety argument:
|
||||
|
||||
1. every candidate's state is fetched — ALL of them, before anything is
|
||||
removed;
|
||||
2. each answer must be an object carrying the number we asked about and a
|
||||
state from the domain's own vocabulary (`confirmed_state`);
|
||||
3. only then is the eviction run, by handing the refreshed issues to
|
||||
`issue_evict.run` — the same decision, the same deletion, the same
|
||||
protection of `origin: local`, in one place.
|
||||
|
||||
A `tea` that will not run, a non-2xx, an answer for another issue, a state
|
||||
nobody recognizes: the run stops at step 2 and NOTHING is deleted, not even the
|
||||
issues whose answers had already arrived. That is stricter than `push.py`, which
|
||||
deletes as it goes, and it costs nothing here — there is no ordering constraint
|
||||
between evictions, so there is no reason to start before every answer is in.
|
||||
|
||||
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
|
||||
none, is never asked about, and is never evicted — it is not in the tracker to
|
||||
be closed. An `origin: gitea` issue whose handle is missing or unparseable
|
||||
cannot be verified, so it is reported and kept rather than guessed at.
|
||||
|
||||
Cost: one GET per candidate. The store is a working set that push keeps small,
|
||||
and a wrong answer here deletes a file, so each issue is asked about by its own
|
||||
address rather than inferred from a list that a `--limit` could have truncated.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_evict # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def candidates(issues, ids=None):
|
||||
"""(checkable, unverifiable) — which issues the tracker can be asked about.
|
||||
|
||||
checkable is [(id, repo, number)] read off the `gitea:` handle, so an issue
|
||||
that lives in another repo is asked about there. unverifiable is
|
||||
[(id, why)]: it names a tracker but carries no handle to reach it by, which
|
||||
is a file to report, never one to delete on a guess.
|
||||
|
||||
An `origin: local` issue is in neither list. It has no handle because it has
|
||||
never left this machine, and asking Gitea about it is not a question that
|
||||
has an answer.
|
||||
"""
|
||||
checkable, unverifiable = [], []
|
||||
for id in (list(ids) if ids else sorted(issues)):
|
||||
iss = issues[id]
|
||||
if iss.is_local:
|
||||
continue
|
||||
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
||||
if not repo or not number:
|
||||
unverifiable.append((id, "origin: %s but no usable `gitea:` handle"
|
||||
% iss.origin))
|
||||
continue
|
||||
checkable.append((id, repo, number))
|
||||
return checkable, unverifiable
|
||||
|
||||
|
||||
def confirmed_state(got, number):
|
||||
"""The state Gitea confirmed for `number`, or None — the deletion gate.
|
||||
|
||||
The counterpart of `push.confirmed_number`, and written the same way: boring,
|
||||
and saying no by default, because everything downstream of a `str` return
|
||||
here may delete a file. An answer counts only when it is a dict, carries the
|
||||
very number we asked about, and names a state the domain recognizes.
|
||||
|
||||
`bool` is rejected explicitly: `True` is an `int` in Python, and an answer
|
||||
about issue `true` is not an answer about issue 42.
|
||||
|
||||
What it does not have to catch, because it never gets here: a non-2xx or a
|
||||
`tea` that would not run at all — `_gitea.api` exits on both.
|
||||
"""
|
||||
if not isinstance(got, dict):
|
||||
return None
|
||||
n = got.get("number")
|
||||
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
||||
return None
|
||||
state = got.get("state")
|
||||
return state if state in issue.STATES else None
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Evict issues Gitea reports as closed from the local store")
|
||||
ap.add_argument("ids", nargs="*",
|
||||
help="issue ids (default: every synced issue in the store)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="ask the tracker and report; write and delete nothing")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
root = args.out
|
||||
if not issue.store_exists(root):
|
||||
_gitea.die("store %s does not exist — nothing to evict" % root)
|
||||
issues = issue.load_all(root)
|
||||
missing = [i for i in args.ids if i not in issues]
|
||||
if missing:
|
||||
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
||||
|
||||
checkable, unverifiable = candidates(issues, args.ids)
|
||||
for id, why in unverifiable:
|
||||
_gitea.warn("%s: %s — kept, and not asked about" % (id, why))
|
||||
if not checkable:
|
||||
print("nothing to check: no issue in the store carries a `gitea:` handle")
|
||||
return 0
|
||||
|
||||
login = _gitea.require_login()
|
||||
|
||||
# ---- every answer first, deletions after -----------------------------
|
||||
fresh = {}
|
||||
for id, repo, number in checkable:
|
||||
got = _gitea.api(login, "%s/issues/%d" % (_gitea.repo_base(repo), number))
|
||||
state = confirmed_state(got, number)
|
||||
if state is None:
|
||||
_gitea.die("%s: the tracker's answer for %s#%d does not confirm a state "
|
||||
"(%.200r). Nothing was evicted."
|
||||
% (id, repo, number, got))
|
||||
fresh[id] = state
|
||||
|
||||
# The store stops lying even about the issues that stay: an answer already
|
||||
# paid for is written back when it disagrees with the file. This is the only
|
||||
# write this script makes, and a dry run makes none.
|
||||
for id, state in sorted(fresh.items()):
|
||||
was = issues[id].state
|
||||
if was == state:
|
||||
continue
|
||||
print("state %s %s -> %s" % (id, was, state))
|
||||
issues[id].state = state
|
||||
if not args.dry_run:
|
||||
issue.save(root, issues[id])
|
||||
|
||||
issue_evict.run(root, issues, [id for id, _, _ in checkable], args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -58,6 +58,37 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
|
||||
per-project by the operator (see `/tea:auth`) and injected by the guard.
|
||||
Config lives in `$XDG_CONFIG_HOME/tea`.
|
||||
|
||||
### `--repo` takes a slug — except where a checkout is required
|
||||
|
||||
A few commands touch local git, not just the API, and for those `--repo`
|
||||
**must be a path to a checkout**; a slug is rejected:
|
||||
|
||||
```
|
||||
Error: local repository required: execute from a repo dir, or specify a path with --repo
|
||||
```
|
||||
|
||||
The message reads like the flag is missing even when it was passed. Confirmed
|
||||
for `pulls create`, `pulls checkout` and `pulls clean` (tea 0.14.x). Everything
|
||||
that is only an API call — `pulls list`, `milestones`, `releases`, `times`,
|
||||
`labels`, `issues` — takes the slug from any directory.
|
||||
|
||||
Three working forms for `pulls create`:
|
||||
|
||||
```bash
|
||||
# 1. cwd inside the checkout, no --repo at all
|
||||
tea pulls create --login "$GITEA_LOGIN" --head feat/x --base main \
|
||||
--title "…" --description "…"
|
||||
|
||||
# 2. from anywhere, --repo as a PATH (this is also the git-worktree answer:
|
||||
# point it at the main checkout)
|
||||
tea pulls create --login "$GITEA_LOGIN" --repo /path/to/checkout \
|
||||
--head feat/x --base main --title "…" --description "…"
|
||||
|
||||
# 3. no checkout in reach — POST it, where owner/repo is a slug again
|
||||
tea api --login "$GITEA_LOGIN" -X POST -d @tmp/pull/x.json \
|
||||
repos/{owner}/{repo}/pulls
|
||||
```
|
||||
|
||||
## Index
|
||||
|
||||
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
||||
@@ -125,7 +156,11 @@ are still fine via entity commands. Always the placeholder, never a login name.
|
||||
|
||||
## Tips
|
||||
|
||||
- Pass `-o json` for structured output when parsing programmatically.
|
||||
- Pass `-o json` for structured output when parsing programmatically — on
|
||||
**entity commands only**. On `tea api`, `-o` is a *file name*: `-o json`
|
||||
writes the response body to a file called `json` and leaves stdout empty.
|
||||
The response is already JSON, so there is nothing to format; use `-` for
|
||||
stdout, or leave the flag off.
|
||||
- Use `--fields, -f` to narrow columns.
|
||||
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
||||
- If a `tea` command is blocked by `tea-guard`: either you forgot
|
||||
+9
-2
@@ -19,9 +19,16 @@ Without args lists PRs; with `<index>` shows PR detail. Fields: `index,state,aut
|
||||
|
||||
Subcommands:
|
||||
- `list, ls` (`--state`)
|
||||
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing.
|
||||
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash.
|
||||
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing. Needs a checkout, same as `create`: `--repo` is a path here, not a slug.
|
||||
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash. Needs a checkout, same as `create`.
|
||||
- `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`).
|
||||
**Needs a local checkout.** `--repo owner/repo` is *not* accepted here — the
|
||||
slug fails with `local repository required: execute from a repo dir, or
|
||||
specify a path with --repo`, whose advice reads like the flag was missing.
|
||||
Run it with cwd inside the checkout and no `--repo`, or pass `--repo
|
||||
/path/to/checkout`. From a git worktree, point `--repo` at the main
|
||||
checkout. With no checkout in reach, `POST repos/{owner}/{repo}/pulls`
|
||||
through `tea api`, which takes the slug.
|
||||
- `close <idx>...`, `reopen, open <idx>...`
|
||||
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
|
||||
- `review <idx>` — interactive review.
|
||||
+1
-1
@@ -26,5 +26,5 @@ Authenticated HTTP request to the Gitea API. Endpoints are auto-prefixed with `/
|
||||
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
|
||||
- `--header/-H key:value` (repeatable)
|
||||
- `--include/-i` — write status + response headers to stderr.
|
||||
- `--output/-o <file>` — write response body to file (`-` = stdout).
|
||||
- `--output/-o <file>` — write response body to file (`-` = stdout). **Not the entity commands' format flag**: `-o json` here creates a file named `json` and prints nothing. The body is already JSON.
|
||||
- Quote the endpoint if it contains `?` or `&` to prevent shell expansion.
|
||||
@@ -14,9 +14,9 @@ Version: `tea 0.14.1` (go-sdk v0.25.1). Source: recursive `--help` traversal. Up
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--login, -l <name>` | use a specific login from the config |
|
||||
| `--repo, -r <owner/repo>` | override repository context (local path or slug) |
|
||||
| `--repo, -r <owner/repo>` | override repository context (local path or slug). **A slug only works where the command is pure API.** `pulls create`, `pulls checkout` and `pulls clean` need a real checkout and read this flag as a path — see [SKILL.md](../../SKILL.md) |
|
||||
| `--remote, -R <name>` | discover login from this git remote |
|
||||
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json` |
|
||||
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json`. **Entity commands only** — on `tea api` the same flag is a FILE NAME, see [HELPERS](./helpers.md) |
|
||||
| `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) |
|
||||
| `--fields, -f <list>` | which columns to print |
|
||||
|
||||
@@ -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()
|
||||
@@ -27,7 +27,6 @@ import unittest
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||
|
||||
sys.path.insert(0, SYNC_SCRIPTS)
|
||||
@@ -44,7 +43,7 @@ FAKE_TEA = '''#!%s
|
||||
import json, os, sys
|
||||
with open(os.path.join(os.environ["TEA_CALL_LOG"], "calls.txt"), "a") as f:
|
||||
f.write("\\t".join(sys.argv[1:]) + "\\n")
|
||||
sys.stdout.write(json.dumps({"id": 1, "name": "created", "sub_url": "Page"})
|
||||
sys.stdout.write(json.dumps({"id": 1, "name": "created"})
|
||||
if "-X" in sys.argv else "[]")
|
||||
'''
|
||||
|
||||
@@ -137,8 +136,20 @@ class TestPayloadRoot(unittest.TestCase):
|
||||
self.assertFalse(os.path.basename(_gitea.PAYLOAD_ROOT).startswith("."))
|
||||
|
||||
def test_gitignore_covers_it(self):
|
||||
with open(os.path.join(REPO, ".gitignore")) as f:
|
||||
ignored = {line.strip() for line in f}
|
||||
"""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")
|
||||
@@ -223,7 +234,7 @@ class TestOnePlaceForEveryCaller(unittest.TestCase):
|
||||
def hits(self, needle, skip_transport=False):
|
||||
"""Every `layer/script.py:line` mentioning `needle`."""
|
||||
out = []
|
||||
for d in (SYNC_SCRIPTS, WIKI_SCRIPTS):
|
||||
d = SYNC_SCRIPTS
|
||||
layer = os.path.basename(os.path.dirname(d))
|
||||
for name in sorted(os.listdir(d)):
|
||||
if not name.endswith(".py") or (skip_transport and name == "_gitea.py"):
|
||||
@@ -236,8 +247,8 @@ class TestOnePlaceForEveryCaller(unittest.TestCase):
|
||||
|
||||
def test_no_caller_chooses_where_its_payload_goes(self):
|
||||
"""Whatever the answer is, it has to be the same for all of them —
|
||||
payload files scattered across two stores and a wiki space is the
|
||||
state this replaced."""
|
||||
payload files scattered across the stores of whichever command wrote
|
||||
them is the state this replaced."""
|
||||
self.assertEqual(self.hits("out_root"), [],
|
||||
"a caller still picks a payload directory of its own")
|
||||
|
||||
@@ -400,9 +400,10 @@ class TestSyncLayerAgrees(unittest.TestCase):
|
||||
"""Both layers agree by construction, not by coincidence: no script
|
||||
spells the default out for itself."""
|
||||
for layer, names in (("issue", ("issue_new.py", "issue_check.py",
|
||||
"issue_tree.py", "issue_index.py")),
|
||||
"issue_tree.py", "issue_index.py",
|
||||
"issue_evict.py")),
|
||||
("sync", ("pull.py", "push.py", "remote.py",
|
||||
"comment.py"))):
|
||||
"comment.py", "evict.py"))):
|
||||
for name in names:
|
||||
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
|
||||
src = f.read()
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
name: page
|
||||
description: Organize a discussion's artifacts into a named, ordered tree of wiki pages — import a directory of markdown, give every file a title, build the index, see what a space holds. Entirely offline; pages are local markdown files and need no wiki. Load when the user asks to turn notes/artifacts into wiki pages, organize or re-title a page tree, or rebuild a table of contents. For fetching from or publishing to a Gitea wiki, load /tea:wiki instead.
|
||||
---
|
||||
|
||||
# /tea:page — discussion artifacts as a page tree
|
||||
|
||||
A discussion produces artifacts wherever the discussion happened — a directory
|
||||
of markdown with numbered files and subdirectories. This skill turns that into
|
||||
a **space**: a named, ordered tree of pages with a manifest, living under
|
||||
`tmp/wiki/`.
|
||||
|
||||
**Nothing here touches the network.** No `tea`, no Gitea, no login. A space that
|
||||
never leaves this machine is a finished thing, not a draft waiting for an
|
||||
upload. Publishing is a separate, optional layer — `/tea:wiki`.
|
||||
|
||||
Read [`references/pages.md`](references/pages.md) before importing or
|
||||
re-titling. It is the single source of truth for titles, ordering, paths, the
|
||||
manifest, and the index.
|
||||
|
||||
## Identity: the title
|
||||
|
||||
`Simple Chains/Ideas/Chain core`. The `/` is the only hierarchy there is — the
|
||||
wiki this feeds is flat and has no directories. The local path is derived from
|
||||
the title (`Simple-Chains/Ideas/Chain-core.md`); the reverse never happens.
|
||||
|
||||
A title is chosen **once**, at import or at pull, and then it is a fact in the
|
||||
manifest. Editing a heading does not rename a page. Renaming is `--retitle`,
|
||||
and on a published page it orphans the old one.
|
||||
|
||||
## Scripts
|
||||
|
||||
All offline, all in `<skill-base-dir>/scripts/`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `page_import.py --from DIR [--space S] [--prefix T]` | copy a directory of markdown into a space, titling every file |
|
||||
| `page_index.py [--space S] [--prefix T]` | write the table-of-contents page — the navigation the flat wiki cannot provide |
|
||||
| `page_ls.py [--space S] [--prefix T]` | the tree, the titles, and one sync-state tag per page |
|
||||
| `page.py` | the domain module the others import — not a command |
|
||||
|
||||
```
|
||||
tmp/wiki/claude-skills/tea/ a space
|
||||
.pages.json the manifest — titles, order, sync bookkeeping
|
||||
Simple-Chains.md the index page
|
||||
Simple-Chains/Ideas.md title: Simple Chains/Ideas
|
||||
Simple-Chains/Ideas/Chain-core.md title: Simple Chains/Ideas/Chain core
|
||||
```
|
||||
|
||||
## The usual run
|
||||
|
||||
```bash
|
||||
python3 scripts/page_import.py \
|
||||
--from ~/proj/tmp/simple-chains \
|
||||
--space claude-skills/tea --prefix "Simple Chains" --dry-run
|
||||
```
|
||||
|
||||
`--dry-run` first, always: it prints every path and the title it would get, and
|
||||
that listing is the only chance to catch a heading that titles a page badly
|
||||
before the name becomes a decision. Drop the flag to write.
|
||||
|
||||
Then the index, then look at it:
|
||||
|
||||
```bash
|
||||
python3 scripts/page_index.py --space claude-skills/tea --prefix "Simple Chains"
|
||||
python3 scripts/page_ls.py --space claude-skills/tea --prefix "Simple Chains"
|
||||
```
|
||||
|
||||
`page_ls.py` tags each page `local` (never published), `synced` (published and
|
||||
unchanged), or `ahead` (edited since it was published). `local` is a complete
|
||||
state.
|
||||
|
||||
## Where the cache is
|
||||
|
||||
`<repo root>/tmp/wiki` — **not** `tmp/wiki` relative to wherever you are
|
||||
standing. The scripts resolve it by walking up from their own file to the
|
||||
nearest `.git` or `AGENTS.md`, so they all see one cache no matter which
|
||||
directory they are run from.
|
||||
|
||||
`--out` overrides that and is taken **literally**: an absolute path is used as
|
||||
given, a relative one stays relative to the current directory.
|
||||
|
||||
## Re-importing is the normal refresh
|
||||
|
||||
The discussion continues, the artifacts change, run the same import again.
|
||||
Bodies are replaced, titles are kept, `sub_url` and the rest of the wiki
|
||||
bookkeeping survive — so the next push updates the pages that already exist
|
||||
instead of publishing a second copy of each.
|
||||
@@ -1,173 +0,0 @@
|
||||
# The page-tree format
|
||||
|
||||
Canonical. Everything about how a discussion's artifacts become named, ordered,
|
||||
navigable pages lives here. The scripts implement this document; when they
|
||||
disagree, this document is right.
|
||||
|
||||
## The one fact that shapes everything: the wiki is flat
|
||||
|
||||
Gitea's wiki has no directories. It has a list of pages, each stored as one
|
||||
file whose name Gitea escapes from the title:
|
||||
|
||||
| title | file Gitea writes | `sub_url` |
|
||||
|---|---|---|
|
||||
| `Abstract Issue` | `Abstract-Issue.md` | `Abstract-Issue` |
|
||||
| `zz-probe/child` | `zz-probe%2Fchild.-.md` | `zz-probe%2Fchild.-` |
|
||||
| `Simple Chains/Parked/Chain decisions — DC` | `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC.md` | same, minus `.md` |
|
||||
|
||||
Three rules are visible in that table, and all three are Gitea's to change:
|
||||
space becomes `-`; `/` becomes `%2F`; a **literal** `-` in the title forces a
|
||||
trailing `.-` marker so it stays distinguishable from a space.
|
||||
|
||||
Two consequences run through the whole design.
|
||||
|
||||
**Hierarchy lives in the title and nowhere else.** `/` inside a title is the
|
||||
only nesting there is. A real subdirectory committed into the wiki's git
|
||||
repository — `folder/page.md` — is invisible to the API and to the web UI. It
|
||||
is a ghost file. Never create one.
|
||||
|
||||
**`sub_url` is identity and is never constructed.** It is read back from
|
||||
whatever the API returned and stored in the manifest. A hand-built one that is
|
||||
almost right does not fail loudly; it creates a second page and abandons the
|
||||
first.
|
||||
|
||||
## The space
|
||||
|
||||
```
|
||||
tmp/wiki/claude-skills/tea/ a SPACE
|
||||
.pages.json the manifest
|
||||
Simple-Chains.md title: Simple Chains (the index)
|
||||
Simple-Chains/
|
||||
Ideas.md title: Simple Chains/Ideas
|
||||
Ideas/
|
||||
Chain-core.md title: Simple Chains/Ideas/Chain core
|
||||
```
|
||||
|
||||
A space is a directory holding a page tree and one manifest. Its name is
|
||||
normally the `owner/repo` it syncs with, and to the domain layer that is an
|
||||
opaque relative path — `--space docs` and `--space a/b/c` are equally valid.
|
||||
|
||||
The path is `<repo root>/tmp/wiki`, resolved from `page.py`'s own location and
|
||||
not from the working directory. `--out` overrides it and is used exactly as
|
||||
typed. Nothing creates a space as a side effect of a write: the scripts say so
|
||||
on stderr when they make one.
|
||||
|
||||
## The manifest
|
||||
|
||||
`.pages.json`, one entry per page, keyed by the file's path inside the space.
|
||||
|
||||
```json
|
||||
{
|
||||
"space": "claude-skills/tea",
|
||||
"pages": {
|
||||
"Simple-Chains/Ideas/Chain-core.md": {
|
||||
"title": "Simple Chains/Ideas/Chain core",
|
||||
"order": 2,
|
||||
"pushed": "9a1ab2e3bfd45f7c7ba323d9d8cd59642d6f0540",
|
||||
"remote-updated": "2026-08-10T11:15:39Z",
|
||||
"sha": "fc8ec1779d910850f49bfef60dd5a0e737bbdc8a",
|
||||
"sub_url": "Simple-Chains%2FIdeas%2FChain-core",
|
||||
"synced": "2026-08-10T11:15:39Z",
|
||||
"url": "https://git.noodles.cam/…/wiki/Simple-Chains%2FIdeas%2FChain-core"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| key | owner | meaning |
|
||||
|---|---|---|
|
||||
| `title` | domain | the page's name; `/` is hierarchy |
|
||||
| `order` | domain | sort key from a `NN-` file-name prefix; absent when there was none |
|
||||
| `sub_url` | wiki | Gitea's address for the page — **the identity** |
|
||||
| `pushed` | wiki | sha1 of the bytes last published; the whole of change detection |
|
||||
| `sha` | wiki | the wiki commit the local copy came from |
|
||||
| `synced` | wiki | when this copy was fetched or pushed |
|
||||
| `url` | wiki | browser link |
|
||||
| `remote-updated` | wiki | the page's last commit date in the wiki |
|
||||
|
||||
The domain layer writes `title` and `order`, carries everything else through
|
||||
load and save verbatim, and never reads it. A page with no `sub_url` has never
|
||||
been published — a complete state, not a pending one, exactly as `origin: local`
|
||||
is for an issue.
|
||||
|
||||
## How a source file gets its title
|
||||
|
||||
Applied at import, once. Three rules, in order:
|
||||
|
||||
1. **`order 0`, or a file literally named `index` / `readme`, is the page for
|
||||
the directory it sits in.** `ideas/00-intro.md` becomes `…/Ideas`, not a
|
||||
child of it. Its title comes from the **directory name**, never from its own
|
||||
heading — a child's title has to extend its parent's exactly, and that file
|
||||
opens with "Ideas for chain business requirements", which no child would
|
||||
ever be prefixed by.
|
||||
2. **Otherwise the file's first markdown heading**, sanitized. It is what a
|
||||
human wrote for a human: there is no mechanical route from
|
||||
`03-q-01-do-we-know-the-chain-participant-by-name.md` to
|
||||
`Q-01. Do We Know the Chain Participant by Name`.
|
||||
3. **No heading: the file name**, made readable — `NN-` stripped, `-` and `_`
|
||||
to spaces, first letter raised. Only the first letter: title-casing would
|
||||
wreck `Q-01`, `sqlc`, and `APNs`.
|
||||
|
||||
Sanitizing a title drops markdown markup (`` ` ``, `*`, `_` — a page list does
|
||||
not render markdown) and turns `/` into `-`, because a slash inside a heading
|
||||
would silently invent a level of hierarchy the author did not ask for.
|
||||
|
||||
### A title is a decision, not a derivation
|
||||
|
||||
Once a page is in the manifest its title stays put. Re-importing replaces the
|
||||
body and leaves the title alone, so editing a heading cannot rename a page —
|
||||
which matters because renaming a **published** page does not move it, it
|
||||
creates a second one and orphans the first. `--retitle` opts into that
|
||||
explicitly.
|
||||
|
||||
The reverse direction does not exist. A path is derived from a title; a title
|
||||
is never derived from a path. `02-chain-core` proves why: those dashes are
|
||||
real, and undoing "space became dash" would eat them.
|
||||
|
||||
## Ordering
|
||||
|
||||
A leading `NN-` on a file name is sort order and nothing else — it never
|
||||
reaches the title. `00` is special and means "this is the directory's own
|
||||
page". Pages with an order sort before pages without one: an explicit `NN-` is
|
||||
a decision, its absence is not.
|
||||
|
||||
The wiki cannot hold ordering, so `order` is local-only and survives a pull.
|
||||
|
||||
## Paths
|
||||
|
||||
A path is one component per title segment, spaces to `-`, with characters a
|
||||
shell has to quote dropped — apostrophes and quotes and commas. `Don't send to
|
||||
this one` keeps its apostrophe in the title and loses it in
|
||||
`Dont-send-to-this-one.md`.
|
||||
|
||||
Two titles can land on one path. That is reported and never resolved
|
||||
automatically: picking a winner is how a discussion loses a document. Rename a
|
||||
source, or rename the page in the wiki, and run it again.
|
||||
|
||||
## The index page
|
||||
|
||||
The wiki will not draw a tree from titles, so an index page is the navigation,
|
||||
not a nicety. `page_index.py` writes one as an ordinary page in the space — it
|
||||
is pushed by the same command as everything else.
|
||||
|
||||
Nesting follows the **titles**, not the manifest's path order; those two
|
||||
disagree, because on disk `Simple-Chains/System.md` sorts before
|
||||
`Simple-Chains/Ideas/Scale.md` while in the hierarchy System is a child and
|
||||
Scale a grandchild. A parent with no page of its own still gets a node, so its
|
||||
children are not hidden.
|
||||
|
||||
Links: a published page is linked by its `sub_url`, the only address Gitea
|
||||
guarantees. A page that has never been pushed gets Gitea's `[[Title|label]]`
|
||||
wiki-link syntax, which resolves the escaping on the server at render time.
|
||||
Rebuilding the index after a push upgrades those links to exact ones — so the
|
||||
order is **push, rebuild the index, push again**.
|
||||
|
||||
## What the sync does not do
|
||||
|
||||
- **No merge.** A pull overwrites the local body. `synced` tells you how old
|
||||
your copy is; re-pull when it matters.
|
||||
- **No drift tracking.** `pushed` answers one question — is the local file
|
||||
different from what was published — and answers it with a hash.
|
||||
- **No deletes.** Pushing is additive. A page removed locally stays in the
|
||||
wiki; removing a published page is an explicit act, done in the web UI or
|
||||
with a `DELETE` through `/tea:use`.
|
||||
@@ -1,523 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""
|
||||
page.py — what a PAGE TREE is. The domain layer for wiki artifacts.
|
||||
|
||||
Not a command; the module the other page scripts build on. It knows how a
|
||||
directory of markdown becomes a named, ordered tree of pages, and it knows
|
||||
NOTHING about any wiki: no Gitea, no `tea`, no logins, no HTTP, no `sub_url`.
|
||||
The layering rule is mechanically checkable — every import in this directory is
|
||||
stdlib, and `subprocess` is not among them:
|
||||
|
||||
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
|
||||
|
||||
Delete skills/wiki/ entirely and this layer keeps working: a discussion's
|
||||
artifacts organized into a tree on this machine are a finished thing, not a
|
||||
draft waiting for an upload.
|
||||
|
||||
tmp/wiki/claude-skills/tea/ <- a SPACE
|
||||
.pages.json <- the manifest
|
||||
Simple-Chains/
|
||||
Ideas.md title: Simple Chains/Ideas
|
||||
Ideas/
|
||||
Chain-core.md title: Simple Chains/Ideas/Chain core
|
||||
|
||||
A space is a directory holding a page tree and one manifest. The space's name
|
||||
("claude-skills/tea") is an opaque relative path to this module — it happens to
|
||||
be an owner/repo pair, and this layer never learns that.
|
||||
|
||||
Why a manifest at all
|
||||
---------------------
|
||||
Because the wiki's own page identity is not derivable from a file path, and
|
||||
guessing at it is how you get duplicate pages. The manifest is the record of
|
||||
what each local file IS, written once at import or pull and never re-derived.
|
||||
|
||||
Domain keys in a manifest entry are `title` and `order`. Everything else —
|
||||
`sub_url`, `sha`, `synced`, `pushed` — is written by the wiki layer, carried
|
||||
through load/save verbatim, and never read here. That passthrough is what lets
|
||||
one manifest describe both a local-only tree and a published one without the
|
||||
domain learning a second vocabulary.
|
||||
|
||||
Titles
|
||||
------
|
||||
The title is the identity that matters, and `/` inside it is the ONLY
|
||||
hierarchy there is — the wiki this feeds has no directories. A local path is
|
||||
derived from the title, never the reverse:
|
||||
|
||||
title "Simple Chains/Ideas/Chain core"
|
||||
path "Simple-Chains/Ideas/Chain-core.md"
|
||||
|
||||
That direction is deliberate. Deriving a title back from a path would have to
|
||||
undo `-`-for-space, and `02-chain-core` proves it cannot: the dashes there are
|
||||
real. So a title is chosen ONCE, at import or at pull, and then it is a fact in
|
||||
the manifest. Renaming is an explicit act, not a side effect of editing a
|
||||
heading.
|
||||
|
||||
Ordering
|
||||
--------
|
||||
A leading `NN-` on a file name is sort order and nothing else — it never
|
||||
reaches the title. `order 0` is special: it is the directory's own page, so
|
||||
`ideas/00-intro.md` becomes the page "…/Ideas" rather than a child of it.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# where the cache lives
|
||||
# --------------------------------------------------------------------------
|
||||
# `<repo root>/tmp/wiki`, absolute, resolved once at import — the same anchoring
|
||||
# rule the issue store uses, and for the same reason: a script's own location is
|
||||
# a fact about the installation, cwd is a fact about the last `cd`. Walking up
|
||||
# from __file__ hands every script in both layers one answer no matter where it
|
||||
# is invoked from.
|
||||
#
|
||||
# The twenty lines below are duplicated from the issue domain rather than
|
||||
# imported from it. Two domains that do not know about each other is worth more
|
||||
# than the duplication is worth saving: skills/page must keep working with
|
||||
# skills/issue deleted, exactly as skills/issue keeps working with skills/sync
|
||||
# deleted.
|
||||
|
||||
STORE_PARTS = ("tmp", "wiki")
|
||||
|
||||
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||
# git; the agents-sync hook only ever puts one at a repository root.
|
||||
REPO_MARKERS = (".git", "AGENTS.md")
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
MANIFEST = ".pages.json"
|
||||
|
||||
# Written here; read here. Everything else in an entry belongs to the wiki
|
||||
# layer and is passed through untouched.
|
||||
DOMAIN_KEYS = ("title", "order", "source")
|
||||
|
||||
|
||||
def repo_root(start):
|
||||
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
|
||||
d = os.path.abspath(start)
|
||||
while True:
|
||||
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
|
||||
return d
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
def store_root(start=None):
|
||||
"""Absolute path of the wiki cache root.
|
||||
|
||||
`start` overrides the anchor so the resolution can be exercised against a
|
||||
scratch tree. Outside a repository, cwd gets a turn, then the historical
|
||||
cwd-relative location stands — made absolute so an error can name the
|
||||
directory it really looked in."""
|
||||
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||
root = repo_root(anchor)
|
||||
if root:
|
||||
return os.path.join(root, *STORE_PARTS)
|
||||
return os.path.abspath(os.path.join(*STORE_PARTS))
|
||||
|
||||
|
||||
WIKI_ROOT = store_root()
|
||||
|
||||
|
||||
def space_root(space, root=None):
|
||||
"""Directory of one space. `space` is an opaque relative path — it may
|
||||
contain `/` (it usually does) and is used as typed."""
|
||||
return os.path.join(root or WIKI_ROOT, *space.split("/"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# names, titles, order
|
||||
# --------------------------------------------------------------------------
|
||||
# Characters a title may not carry into a path. `/` is absent on purpose: it is
|
||||
# the hierarchy separator and is split on before this ever applies.
|
||||
_UNSAFE = re.compile(r'[\\:*?"<>|\x00-\x1f]+')
|
||||
# Inline code in a heading is markup, not a name: `Inventory — \`P-NN\`` is a
|
||||
# page called "Inventory — P-NN", and a page list does not render markdown.
|
||||
_MARKUP = re.compile(r"[`*_]+")
|
||||
# Dropped from a PATH but kept in a title. An apostrophe in "Don't send to this
|
||||
# one" belongs in the name and does not belong in something a shell has to
|
||||
# quote.
|
||||
_PATH_NOISE = re.compile(r"['‘’\"“”,]+")
|
||||
_DASHES = re.compile(r"-{2,}")
|
||||
_ORDER = re.compile(r"^(\d+)[-_. ]+(.*)$")
|
||||
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$")
|
||||
|
||||
|
||||
def order_of(name):
|
||||
"""The `NN-` sort key on a file or directory name, or None.
|
||||
|
||||
`00-intro.md` -> 0, `02-chain-core.md` -> 2, `handoff.md` -> None. Zero is
|
||||
a real answer and not None; callers distinguish them."""
|
||||
m = _ORDER.match(strip_ext(name))
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def strip_ext(name):
|
||||
stem, ext = os.path.splitext(name)
|
||||
return stem if ext.lower() in (".md", ".markdown") else name
|
||||
|
||||
|
||||
def strip_order(name):
|
||||
"""`02-chain-core` -> `chain-core`; a name that is only digits is left
|
||||
alone, because stripping it would leave nothing to call the page."""
|
||||
m = _ORDER.match(strip_ext(name))
|
||||
return m.group(2) if m and m.group(2) else strip_ext(name)
|
||||
|
||||
|
||||
def title_from_name(name):
|
||||
"""Fallback title: the file or directory name made readable.
|
||||
|
||||
`02-chain-core.md` -> `Chain core`. Only the first letter is raised —
|
||||
title-casing would wreck `Q-01`, `sqlc`, `APNs`, and every other name that
|
||||
already knows how it is spelled."""
|
||||
t = strip_order(name).replace("_", " ").replace("-", " ").strip()
|
||||
t = re.sub(r"\s+", " ", t)
|
||||
return t[:1].upper() + t[1:] if t else t
|
||||
|
||||
|
||||
def title_from_body(text):
|
||||
"""The document's first markdown heading, or None.
|
||||
|
||||
Preferred over the file name because it is what a human wrote for a human:
|
||||
`03-q-01-do-we-know-the-chain-participant-by-name.md` opens with
|
||||
`## Q-01. Do We Know the Chain Participant by Name`, and there is no
|
||||
mechanical route from the first string to the second. Only the first
|
||||
heading is consulted, and only before any prose — a heading further down is
|
||||
a section, not a name."""
|
||||
for line in text.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
m = _HEADING.match(line)
|
||||
return m.group(1).strip() if m else None
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_title(title):
|
||||
"""Make a string safe to be one title SEGMENT.
|
||||
|
||||
`/` becomes `-`: a slash inside a heading would silently invent a level of
|
||||
hierarchy that the author did not ask for, and inventing structure is worse
|
||||
than losing a slash."""
|
||||
t = _MARKUP.sub("", _UNSAFE.sub("", title.replace("/", "-")))
|
||||
return re.sub(r"\s+", " ", t).strip(" .-") or "untitled"
|
||||
|
||||
|
||||
def join_title(*parts):
|
||||
"""Join title segments with the hierarchy separator, dropping empties."""
|
||||
return "/".join(p for p in parts if p)
|
||||
|
||||
|
||||
def path_segment(segment):
|
||||
"""One title segment as one path component."""
|
||||
s = _PATH_NOISE.sub("", _MARKUP.sub("", _UNSAFE.sub("", segment)))
|
||||
s = re.sub(r"\s+", "-", s.replace("/", "-").strip())
|
||||
return _DASHES.sub("-", s).strip("-.") or "untitled"
|
||||
|
||||
|
||||
def path_for_title(title):
|
||||
"""Relative path, inside a space, for a title. Always ends in `.md`."""
|
||||
parts = [path_segment(p) for p in title.split("/") if p.strip()]
|
||||
if not parts:
|
||||
parts = ["untitled"]
|
||||
return os.path.join(*parts) + ".md"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the manifest
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def blank_manifest(space):
|
||||
return {"space": space, "pages": {}}
|
||||
|
||||
|
||||
def manifest_path(space, root=None):
|
||||
return os.path.join(space_root(space, root), MANIFEST)
|
||||
|
||||
|
||||
def load_manifest(space, root=None):
|
||||
"""The space's manifest, or a blank one.
|
||||
|
||||
A missing manifest and an empty one are the same thing to every caller here
|
||||
— but they are NOT the same thing to a caller deciding whether to print
|
||||
"no such space". That distinction is `os.path.isdir(space_root(...))`, and
|
||||
the commands make it themselves rather than reading it out of a dict."""
|
||||
p = manifest_path(space, root)
|
||||
if not os.path.isfile(p):
|
||||
return blank_manifest(space)
|
||||
with open(p, encoding="utf-8") as f:
|
||||
m = json.load(f)
|
||||
m.setdefault("space", space)
|
||||
m.setdefault("pages", {})
|
||||
return m
|
||||
|
||||
|
||||
def save_manifest(manifest, root=None):
|
||||
"""Write the manifest, keys sorted, one page per line-block.
|
||||
|
||||
Sorted and indented because this file lands in a diff every time anything
|
||||
syncs, and a diff nobody can read is a diff nobody checks."""
|
||||
p = manifest_path(manifest["space"], root)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
ordered = {"space": manifest["space"], "pages": {}}
|
||||
for path, e in sorted(manifest.get("pages", {}).items()):
|
||||
ordered["pages"][path] = {k: e[k] for k in DOMAIN_KEYS if k in e}
|
||||
ordered["pages"][path].update(
|
||||
{k: v for k, v in sorted(e.items()) if k not in DOMAIN_KEYS})
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
json.dump(ordered, f, ensure_ascii=False, indent=2, sort_keys=False)
|
||||
f.write("\n")
|
||||
return p
|
||||
|
||||
|
||||
def entry(title, order=None, source=None, **extra):
|
||||
"""A manifest entry. Domain keys first, passthrough after — the same
|
||||
render order the issue layer uses, for the same reason: it makes a diff of
|
||||
the file readable."""
|
||||
e = {"title": title}
|
||||
if order is not None:
|
||||
e["order"] = order
|
||||
if source is not None:
|
||||
e["source"] = source
|
||||
e.update({k: v for k, v in extra.items() if v is not None})
|
||||
return e
|
||||
|
||||
|
||||
def find_by_source(manifest, source, prefix=""):
|
||||
"""(relpath, entry) for the page imported from this source file, or
|
||||
(None, None).
|
||||
|
||||
The path is derived from the title, so a retitle moves it — and looking a
|
||||
page up by its new path would find nothing, treat it as new, and publish a
|
||||
duplicate beside the page it was meant to rename. Source is the one link
|
||||
that survives a rename, which is why it is recorded at all.
|
||||
|
||||
Scoped by title prefix, so importing the same directory twice under two
|
||||
prefixes gives two independent trees rather than one fighting over itself.
|
||||
"""
|
||||
for path, e in manifest.get("pages", {}).items():
|
||||
if e.get("source") != source:
|
||||
continue
|
||||
if prefix and not (e.get("title", "") == prefix
|
||||
or e.get("title", "").startswith(prefix + "/")):
|
||||
continue
|
||||
return path, e
|
||||
return None, None
|
||||
|
||||
|
||||
def sort_key(relpath, e):
|
||||
"""Order a tree for display and for an index.
|
||||
|
||||
Directory by directory, `order` first and unnumbered pages after — an
|
||||
explicit `NN-` is a decision, its absence is not. Ties break on title so
|
||||
the output is stable."""
|
||||
d = os.path.dirname(relpath)
|
||||
o = e.get("order")
|
||||
return (d, 0 if o is not None else 1, o if o is not None else 0,
|
||||
e.get("title", relpath))
|
||||
|
||||
|
||||
def sorted_pages(manifest):
|
||||
"""[(relpath, entry)] in tree order."""
|
||||
return sorted(manifest.get("pages", {}).items(),
|
||||
key=lambda kv: sort_key(kv[0], kv[1]))
|
||||
|
||||
|
||||
def by_title(manifest):
|
||||
return {e["title"]: (p, e) for p, e in manifest.get("pages", {}).items()
|
||||
if e.get("title")}
|
||||
|
||||
|
||||
def children_of(manifest, prefix):
|
||||
"""Every page at or under a title prefix.
|
||||
|
||||
The wiki this feeds is flat, so "children" is a prefix test on the title
|
||||
and nothing more — there is no tree to walk, only a naming convention to
|
||||
trust."""
|
||||
out = []
|
||||
for p, e in sorted_pages(manifest):
|
||||
t = e.get("title", "")
|
||||
if t == prefix or t.startswith(prefix + "/"):
|
||||
out.append((p, e))
|
||||
return out
|
||||
|
||||
|
||||
def body_hash(text):
|
||||
"""sha1 of the exact bytes a page would be published as.
|
||||
|
||||
This is the whole of change detection: a page is worth pushing when what is
|
||||
on disk hashes differently from what was pushed last. No timestamps, no
|
||||
drift model — the same stance the issue store takes."""
|
||||
if isinstance(text, str):
|
||||
text = text.encode("utf-8")
|
||||
return hashlib.sha1(text).hexdigest()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# importing a directory of markdown
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
SKIP_DIRS = {".git", ".svn", "__pycache__", "node_modules"}
|
||||
MD_EXT = (".md", ".markdown")
|
||||
|
||||
|
||||
def walk_markdown(src):
|
||||
"""Every markdown file under `src`, as paths relative to it, depth first
|
||||
and sorted so an import is reproducible."""
|
||||
out = []
|
||||
for dirpath, dirnames, filenames in os.walk(src):
|
||||
dirnames[:] = sorted(d for d in dirnames
|
||||
if d not in SKIP_DIRS and not d.startswith("."))
|
||||
rel = os.path.relpath(dirpath, src)
|
||||
rel = "" if rel == "." else rel
|
||||
for f in sorted(filenames):
|
||||
if f.lower().endswith(MD_EXT) and not f.startswith("."):
|
||||
out.append(os.path.join(rel, f) if rel else f)
|
||||
return out
|
||||
|
||||
|
||||
def title_for_source(relpath, text, prefix=""):
|
||||
"""The title a source file gets on import.
|
||||
|
||||
Three rules, in this order, and the reference doc spells out why:
|
||||
|
||||
1. `order 0` (`00-intro.md`, or a literal `index`/`readme`) is the page for
|
||||
the directory it sits in. Its title comes from the DIRECTORY name, not
|
||||
from its own heading — a child's title must extend its parent's exactly,
|
||||
and `ideas/00-intro.md` opens with "Ideas for chain business
|
||||
requirements", which no child would ever be prefixed by.
|
||||
2. Any other file takes its first heading, sanitized.
|
||||
3. No heading: the file name, made readable.
|
||||
"""
|
||||
parts = relpath.replace(os.sep, "/").split("/")
|
||||
name = parts[-1]
|
||||
dirs = [sanitize_title(title_from_name(d)) for d in parts[:-1]]
|
||||
|
||||
stem = strip_ext(name).lower()
|
||||
if order_of(name) == 0 or stem in ("index", "readme"):
|
||||
# The directory's own page. At the root of the import that is the
|
||||
# prefix itself.
|
||||
return join_title(prefix, *dirs)
|
||||
|
||||
own = title_from_body(text)
|
||||
own = sanitize_title(own) if own else sanitize_title(title_from_name(name))
|
||||
return join_title(prefix, *dirs, own)
|
||||
|
||||
|
||||
def plan_import(src, prefix="", read=None):
|
||||
"""Work out what an import would produce, without writing anything.
|
||||
|
||||
Returns (pages, collisions):
|
||||
pages [{"source", "path", "title", "order", "text"}] in tree order
|
||||
collisions [(path, [title, title, ...])] — two sources landing on one
|
||||
file. Reported, never resolved: the wiki would end up with
|
||||
two pages fighting over one local copy, and picking a winner
|
||||
for the operator is how a discussion loses a document."""
|
||||
def default_read(p):
|
||||
with open(p, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
read = read or default_read
|
||||
pages, seen = [], {}
|
||||
for rel in walk_markdown(src):
|
||||
source = os.path.join(src, rel)
|
||||
text = read(source)
|
||||
title = title_for_source(rel, text, prefix)
|
||||
path = path_for_title(title)
|
||||
seen.setdefault(path, []).append(title)
|
||||
# `source` is kept relative to the import root, not absolute: it is the
|
||||
# only durable link between a file on the far side and the page it
|
||||
# became, and it has to survive the artifacts directory being moved.
|
||||
pages.append({"source": source, "rel": rel.replace(os.sep, "/"),
|
||||
"path": path, "title": title,
|
||||
"order": order_of(os.path.basename(rel)), "text": text})
|
||||
pages.sort(key=lambda p: sort_key(p["path"], p))
|
||||
collisions = [(p, t) for p, t in sorted(seen.items()) if len(t) > 1]
|
||||
return pages, collisions
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# rendering
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def title_tree(manifest, prefix=""):
|
||||
"""Group pages into a parent -> children map keyed by title.
|
||||
|
||||
Built from the titles, not from the manifest's path order. Those two
|
||||
disagree: on disk `Simple-Chains/System.md` sorts before
|
||||
`Simple-Chains/Ideas/Scale.md`, while in the hierarchy Scale is a
|
||||
grandchild of Simple Chains and System is a child. Nesting has to follow
|
||||
the titles, because the titles are the only hierarchy there is.
|
||||
|
||||
A parent with no page of its own still gets a node: `Simple Chains/Parked`
|
||||
can have children while nothing is published at that title, and dropping
|
||||
its children because it is missing would hide them entirely."""
|
||||
kids, entries = {}, {}
|
||||
for _, e in manifest.get("pages", {}).items():
|
||||
title = e.get("title")
|
||||
if not title:
|
||||
continue
|
||||
if prefix and not (title == prefix or title.startswith(prefix + "/")):
|
||||
continue
|
||||
entries[title] = e
|
||||
parts = title.split("/")
|
||||
# Every ancestor gets a node, so a gap in the chain does not orphan a
|
||||
# subtree.
|
||||
for i in range(len(parts), 0, -1):
|
||||
kids.setdefault("/".join(parts[:i - 1]), set()).add("/".join(parts[:i]))
|
||||
return kids, entries
|
||||
|
||||
|
||||
def render_index(manifest, prefix="", heading=None):
|
||||
"""A table-of-contents page for a space or a subtree.
|
||||
|
||||
Nested markdown list, indented by title depth. The wiki is flat and will
|
||||
not draw this for you, so the index IS the navigation.
|
||||
|
||||
Links: a published page is linked by its `sub_url`, which is the only
|
||||
address Gitea guarantees. A page that has never been pushed has no sub_url
|
||||
yet, so it gets Gitea's own `[[Title]]` wiki-link syntax — which resolves
|
||||
the escaping itself, at render time, on the server. Rebuilding the index
|
||||
after a push upgrades those links to exact ones."""
|
||||
kids, entries = title_tree(manifest, prefix)
|
||||
lines = ["# %s" % (heading or prefix or "Contents"), ""]
|
||||
|
||||
def order_key(title):
|
||||
e = entries.get(title) or {}
|
||||
o = e.get("order")
|
||||
return (0 if o is not None else 1, o if o is not None else 0, title)
|
||||
|
||||
def walk(node, depth):
|
||||
for child in sorted(kids.get(node, ()), key=order_key):
|
||||
e = entries.get(child) or {}
|
||||
label = child.split("/")[-1]
|
||||
sub = e.get("sub_url")
|
||||
link = "[%s](%s)" % (label, sub) if sub else "[[%s|%s]]" % (child, label)
|
||||
lines.append("%s- %s" % (" " * depth, link))
|
||||
walk(child, depth + 1)
|
||||
|
||||
walk(prefix, 0)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def tree_lines(manifest, mark=None):
|
||||
"""The space as an ascii tree, for a terminal.
|
||||
|
||||
`mark(relpath, entry)` returns a short state tag shown after the title —
|
||||
the wiki layer passes sync state through it, and this module stays unaware
|
||||
of what the tags mean."""
|
||||
out, last_dir = [], None
|
||||
for path, e in sorted_pages(manifest):
|
||||
d = os.path.dirname(path)
|
||||
if d != last_dir:
|
||||
out.append("%s/" % d if d else ".")
|
||||
last_dir = d
|
||||
tag = mark(path, e) if mark else ""
|
||||
out.append(" %-40s %s%s" % (os.path.basename(path),
|
||||
e.get("title", ""),
|
||||
(" " + tag) if tag else ""))
|
||||
return out
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
page_import.py — pull a directory of markdown into a space. Offline.
|
||||
|
||||
This is the "wiki organization" step, and it is the only step where a page gets
|
||||
its name. A discussion produces artifacts wherever the discussion happened:
|
||||
|
||||
~/…/mpns/feat/simple-chains/tmp/simple-chains/
|
||||
handoff.md scope.md
|
||||
ideas/00-intro.md ideas/02-chain-core.md
|
||||
questions/03-q-01-do-we-know-the-chain-participant-by-name.md
|
||||
|
||||
Import copies that tree into a space under `tmp/wiki/`, gives every file a
|
||||
title, and records both in the manifest. Nothing here talks to a wiki; the
|
||||
result is a complete, readable, greppable tree whether or not it is ever
|
||||
published.
|
||||
|
||||
page_import.py --from DIR --space claude-skills/tea --prefix "Simple Chains"
|
||||
|
||||
Simple-Chains/Handoff.md Simple Chains/Handoff
|
||||
Simple-Chains/Ideas.md Simple Chains/Ideas
|
||||
Simple-Chains/Ideas/Chain-core.md Simple Chains/Ideas/Chain core
|
||||
|
||||
Re-importing is safe and is the normal way to refresh: a page already in the
|
||||
manifest keeps its title (a title is a decision, not a derivation) and only its
|
||||
body is replaced. `--retitle` opts into re-deriving titles, which is a rename
|
||||
and, for pages already published, will orphan the old ones — so it is never the
|
||||
default.
|
||||
|
||||
Usage:
|
||||
page_import.py --from DIR [--space SPACE] [--prefix TITLE]
|
||||
[--retitle] [--dry-run] [--out DIR]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import page # noqa: E402
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--from", dest="src", required=True,
|
||||
help="directory of markdown to import")
|
||||
ap.add_argument("--space", default="local",
|
||||
help="space to import into (default: local)")
|
||||
ap.add_argument("--prefix", default="",
|
||||
help="title every imported page hangs under")
|
||||
ap.add_argument("--retitle", action="store_true",
|
||||
help="re-derive titles of pages already in the manifest "
|
||||
"(a rename; orphans published pages)")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||
a = ap.parse_args()
|
||||
|
||||
src = os.path.abspath(a.src)
|
||||
if not os.path.isdir(src):
|
||||
die("not a directory: %s" % a.src)
|
||||
|
||||
root = a.out or page.WIKI_ROOT
|
||||
prefix = page.sanitize_title(a.prefix) if a.prefix else ""
|
||||
|
||||
pages, collisions = page.plan_import(src, prefix)
|
||||
if not pages:
|
||||
die("no markdown found under %s" % src)
|
||||
if collisions:
|
||||
for path, titles in collisions:
|
||||
sys.stderr.write("collision: %s <- %s\n" % (path, " | ".join(titles)))
|
||||
die("%d path collision(s); rename the sources and retry" % len(collisions))
|
||||
|
||||
manifest = page.load_manifest(a.space, root)
|
||||
known = manifest["pages"]
|
||||
dest_root = page.space_root(a.space, root)
|
||||
# Asked before anything is written: nothing should create a space as a
|
||||
# silent side effect of a write, and saying so on stderr is how the
|
||||
# operator learns a typo in --space made a second one.
|
||||
created = not os.path.isdir(dest_root)
|
||||
|
||||
new = changed = same = moved = 0
|
||||
for p in pages:
|
||||
# Looked up by SOURCE, not by path: a retitle moves the path, and a
|
||||
# lookup that missed would treat the page as new and publish a
|
||||
# duplicate beside the one it was meant to rename.
|
||||
prior_path, prior = page.find_by_source(manifest, p["rel"], prefix)
|
||||
if prior is None:
|
||||
prior_path, prior = p["path"], known.get(p["path"])
|
||||
|
||||
# A title already in the manifest is a decision that was made once.
|
||||
# Re-deriving it on every import would let an edited heading silently
|
||||
# rename a published page — which does not rename it, it creates a
|
||||
# second one and abandons the first.
|
||||
title = p["title"] if (a.retitle or not prior) else prior["title"]
|
||||
relpath = page.path_for_title(title)
|
||||
dest = os.path.join(dest_root, relpath)
|
||||
|
||||
state = "new"
|
||||
if prior and relpath != prior_path:
|
||||
state = "moved"
|
||||
elif prior and os.path.isfile(dest):
|
||||
with open(dest, encoding="utf-8") as f:
|
||||
state = "same" if f.read() == p["text"] else "changed"
|
||||
elif prior:
|
||||
state = "changed"
|
||||
|
||||
new += state == "new"
|
||||
changed += state == "changed"
|
||||
same += state == "same"
|
||||
moved += state == "moved"
|
||||
|
||||
print("%-7s %-44s %s" % (state, relpath, title))
|
||||
if a.dry_run:
|
||||
continue
|
||||
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
shutil.copyfile(p["source"], dest)
|
||||
# Passthrough keys survive: a re-import must not cost a page its
|
||||
# sub_url, or the next push would publish a duplicate.
|
||||
e = dict(prior or {})
|
||||
e.update(page.entry(title, p["order"], p["rel"]))
|
||||
if state == "moved":
|
||||
# The old copy goes, the entry moves with its bookkeeping intact.
|
||||
# The page in the wiki is still at its old sub_url; the next push
|
||||
# sends the new title, which is what renames it there.
|
||||
old = os.path.join(dest_root, prior_path)
|
||||
if os.path.isfile(old):
|
||||
os.remove(old)
|
||||
known.pop(prior_path, None)
|
||||
# A rename can leave the body byte-identical, and push decides by
|
||||
# body hash alone. Clearing it is what makes the next push send the
|
||||
# new title instead of skipping the page as unchanged.
|
||||
e.pop("pushed", None)
|
||||
known[relpath] = e
|
||||
|
||||
if a.dry_run:
|
||||
print("\ndry run — nothing written")
|
||||
return 0
|
||||
|
||||
path = page.save_manifest(manifest, root)
|
||||
if created:
|
||||
sys.stderr.write("created space %s\n" % dest_root)
|
||||
print("\n%d new, %d changed, %d unchanged%s -> %s"
|
||||
% (new, changed, same,
|
||||
", %d renamed" % moved if moved else "", os.path.dirname(path)))
|
||||
if moved:
|
||||
sys.stderr.write(
|
||||
"%d page(s) renamed. A published page is renamed in the wiki by "
|
||||
"the next push, not by this import.\n" % moved)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
page_index.py — write a table-of-contents page into a space. Offline.
|
||||
|
||||
The wiki this feeds is flat: a title like `Simple Chains/Ideas/Chain core` has
|
||||
hierarchy in its name and nowhere else, and Gitea will not draw you a tree from
|
||||
it. An index page is therefore not a nicety, it is the navigation.
|
||||
|
||||
Written as an ordinary page in the space, so it is pushed by the same command
|
||||
as everything else and needs no special case anywhere downstream. Links are
|
||||
written by TITLE rather than by URL — the wiki resolves those itself, and a
|
||||
link written that way survives every filename-escaping rule this layer
|
||||
deliberately refuses to model.
|
||||
|
||||
page_index.py --space claude-skills/tea --prefix "Simple Chains"
|
||||
-> Simple-Chains.md, title `Simple Chains`
|
||||
|
||||
page_index.py --space claude-skills/tea --title Home
|
||||
-> Home.md, title `Home`, listing the whole space
|
||||
|
||||
Usage:
|
||||
page_index.py [--space SPACE] [--prefix TITLE] [--title TITLE]
|
||||
[--dry-run] [--out DIR]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import page # noqa: E402
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--space", default="local")
|
||||
ap.add_argument("--prefix", default="",
|
||||
help="index only this subtree; also the index's own title")
|
||||
ap.add_argument("--title", help="title for the index page "
|
||||
"(default: --prefix, else Home)")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||
a = ap.parse_args()
|
||||
|
||||
root = a.out or page.WIKI_ROOT
|
||||
space_dir = page.space_root(a.space, root)
|
||||
if not os.path.isdir(space_dir):
|
||||
die("no such space: %s (looked in %s)" % (a.space, space_dir))
|
||||
|
||||
manifest = page.load_manifest(a.space, root)
|
||||
prefix = page.sanitize_title(a.prefix) if a.prefix else ""
|
||||
title = a.title or prefix or "Home"
|
||||
|
||||
body = page.render_index(manifest, prefix, heading=title)
|
||||
relpath = page.path_for_title(title)
|
||||
|
||||
if a.dry_run:
|
||||
sys.stdout.write(body)
|
||||
print("-> %s (%s)" % (relpath, title))
|
||||
return 0
|
||||
|
||||
dest = os.path.join(space_dir, relpath)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
f.write(body)
|
||||
|
||||
# Carries the entry's passthrough keys forward: rebuilding an index must
|
||||
# update the page that is already published, never publish a second one.
|
||||
prior = manifest["pages"].get(relpath, {})
|
||||
e = dict(prior)
|
||||
e.update(page.entry(title, prior.get("order")))
|
||||
manifest["pages"][relpath] = e
|
||||
page.save_manifest(manifest, root)
|
||||
|
||||
n = len(page.children_of(manifest, prefix) if prefix
|
||||
else page.sorted_pages(manifest))
|
||||
print("%s -> %s (%d entr%s)" % (title, relpath, n - 1,
|
||||
"y" if n - 1 == 1 else "ies"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
page_ls.py — show what a space holds. Offline.
|
||||
|
||||
The tree, the titles, and one state tag per page. The tag is the only place
|
||||
this layer acknowledges that a wiki exists, and it reads it the way the issue
|
||||
index reads `origin:` — as an opaque fact recorded by somebody else:
|
||||
|
||||
local never published; a complete state, not a pending one
|
||||
synced published, and the file matches what was pushed
|
||||
ahead published, and the local file has changed since
|
||||
? published, but nothing recorded what was pushed
|
||||
|
||||
Usage:
|
||||
page_ls.py [--space SPACE] [--prefix TITLE] [--titles] [--out DIR]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import page # noqa: E402
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def state_of(space_dir, relpath, e):
|
||||
if not e.get("sub_url"):
|
||||
return "local"
|
||||
pushed = e.get("pushed")
|
||||
if not pushed:
|
||||
return "?"
|
||||
full = os.path.join(space_dir, relpath)
|
||||
if not os.path.isfile(full):
|
||||
return "missing"
|
||||
with open(full, encoding="utf-8") as f:
|
||||
return "synced" if page.body_hash(f.read()) == pushed else "ahead"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--space", default="local")
|
||||
ap.add_argument("--prefix", default="", help="only titles at or under this")
|
||||
ap.add_argument("--titles", action="store_true",
|
||||
help="print one title per line and nothing else")
|
||||
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||
a = ap.parse_args()
|
||||
|
||||
root = a.out or page.WIKI_ROOT
|
||||
space_dir = page.space_root(a.space, root)
|
||||
# "Does not exist" and "is empty" are different answers and get different
|
||||
# messages — an empty space is a space somebody made on purpose.
|
||||
if not os.path.isdir(space_dir):
|
||||
die("no such space: %s (looked in %s)" % (a.space, space_dir))
|
||||
|
||||
manifest = page.load_manifest(a.space, root)
|
||||
pages = (page.children_of(manifest, a.prefix) if a.prefix
|
||||
else page.sorted_pages(manifest))
|
||||
if not pages:
|
||||
print("space %s is empty" % a.space if not a.prefix
|
||||
else "nothing at or under %r" % a.prefix)
|
||||
return 0
|
||||
|
||||
if a.titles:
|
||||
for _, e in pages:
|
||||
print(e.get("title", ""))
|
||||
return 0
|
||||
|
||||
sub = {p: e for p, e in pages}
|
||||
view = dict(manifest, pages=sub)
|
||||
for line in page.tree_lines(view, mark=lambda p, e: state_of(space_dir, p, e)):
|
||||
print(line)
|
||||
|
||||
counts = {}
|
||||
for p, e in pages:
|
||||
s = state_of(space_dir, p, e)
|
||||
counts[s] = counts.get(s, 0) + 1
|
||||
print("\n%d page(s): %s" % (len(pages),
|
||||
", ".join("%d %s" % (v, k)
|
||||
for k, v in sorted(counts.items()))))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
name: wiki
|
||||
description: Move wiki pages between a local space and Gitea — fetch a page and everything under it as a local cache, publish a page tree with an update message, list what the wiki holds. Load when the user asks to read/fetch a wiki page, cache a wiki subtree for a discussion, or publish artifacts to the wiki. Organizing artifacts into a page tree (titles, ordering, the index) is /tea:page and needs no network.
|
||||
---
|
||||
|
||||
# /tea:wiki — the bridge between a local space and a Gitea wiki
|
||||
|
||||
One job: translate between `tmp/wiki/<space>/` and Gitea's wiki JSON, and carry
|
||||
the result over the wire. Everything about **what a page tree is** — titles,
|
||||
ordering, paths, the index — belongs to `/tea:page` and is imported from there,
|
||||
never redefined here.
|
||||
|
||||
Transport is `tea api` through `skills/sync/scripts/_gitea.py`: the same login
|
||||
pin, the same pagination, the same payload files in the same `tmp/payload/`.
|
||||
There is no second transport.
|
||||
|
||||
## The wiki is flat, and that is the whole design
|
||||
|
||||
Gitea's wiki is a list of pages, not a tree. Nesting exists only inside a
|
||||
title, as `/`, and Gitea escapes that title into a filename by rules that are
|
||||
its own:
|
||||
|
||||
| title | `sub_url` |
|
||||
|---|---|
|
||||
| `Abstract Issue` | `Abstract-Issue` |
|
||||
| `zz-probe/child` | `zz-probe%2Fchild.-` |
|
||||
| `Simple Chains/Parked/Chain decisions — DC` | `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC` |
|
||||
|
||||
**`sub_url` is the identity and is never constructed.** It is read back from
|
||||
the API and stored in the manifest. Building one by hand that is almost right
|
||||
does not fail loudly — it creates a second page and abandons the first.
|
||||
|
||||
**Never commit a subdirectory into the wiki's git repository.** A real
|
||||
`folder/page.md` is invisible to the API and to the web UI. It is a ghost file.
|
||||
Do not clone the wiki repo to work in; use these scripts.
|
||||
|
||||
## Scripts
|
||||
|
||||
In `<skill-base-dir>/scripts/`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `wiki_ls.py [--prefix T]` | what the wiki actually holds — titles, `sub_url`, last commit. One call, no bodies |
|
||||
| `wiki_pull.py [--prefix T] [--space S]` | fetch a page and everything under it into a local space |
|
||||
| `wiki_push.py -m MSG [--prefix T] [PATH…]` | publish; create what is new, update what changed, skip what is not |
|
||||
| `wikimap.py` | md ↔ wiki JSON, pure — not a command |
|
||||
|
||||
## Fetching a subtree as a cache
|
||||
|
||||
"A page and its children" is a prefix test on the title, run against one
|
||||
listing call, followed by one GET per page. There is no tree endpoint and no
|
||||
bulk-body endpoint.
|
||||
|
||||
```bash
|
||||
python3 scripts/wiki_ls.py --prefix "Simple Chains" # what is there
|
||||
python3 scripts/wiki_pull.py --prefix "Simple Chains" # cache it locally
|
||||
```
|
||||
|
||||
A pull **overwrites** the local body — a fetch, not a merge. `synced` tells you
|
||||
how old your copy is; re-pull when it matters. Nothing tracks drift.
|
||||
|
||||
The space defaults to the repo's own `owner/repo`, so a pull with no flags
|
||||
caches this repo's whole wiki into `tmp/wiki/<owner>/<repo>/`.
|
||||
|
||||
## Publishing
|
||||
|
||||
```bash
|
||||
python3 scripts/wiki_push.py -m "Import the simple-chains discussion" --dry-run
|
||||
python3 scripts/wiki_push.py -m "Import the simple-chains discussion"
|
||||
```
|
||||
|
||||
`-m` is required and is the wiki commit message — the only record of why a page
|
||||
changed, and it shows up in `wiki/revisions/<sub_url>`. One operation, one
|
||||
message.
|
||||
|
||||
Change detection is a hash: a page whose file matches `pushed` is skipped.
|
||||
A page with no `sub_url` is created; one with a `sub_url` is edited in place,
|
||||
using the title **from the manifest** — sending a different title to the edit
|
||||
endpoint is a rename and leaves nothing at the old address.
|
||||
|
||||
Selection, narrowest first: positional `PATH`-or-`TITLE` arguments (matched
|
||||
exactly), then `--prefix`, then the whole space.
|
||||
|
||||
**Pushing is additive.** A page deleted locally is not deleted in the wiki.
|
||||
Removing a published page is an explicit act — the web UI, or
|
||||
`tea api --login "$GITEA_LOGIN" -X DELETE repos/{owner}/{repo}/wiki/page/<sub_url>`.
|
||||
|
||||
## Order of operations for a fresh tree
|
||||
|
||||
The index links published pages by `sub_url`, which does not exist until the
|
||||
first push. So:
|
||||
|
||||
```bash
|
||||
python3 ../page/scripts/page_import.py --from DIR --prefix "Simple Chains"
|
||||
python3 scripts/wiki_push.py -m "Import the simple-chains discussion"
|
||||
python3 ../page/scripts/page_index.py --prefix "Simple Chains" # now with real links
|
||||
python3 scripts/wiki_push.py -m "Index"
|
||||
```
|
||||
|
||||
## Linking an issue to a page
|
||||
|
||||
An issue's `wiki:` field holds page **titles**, not URLs — a title is a name for
|
||||
a document and stays in the domain; the URL is bookkeeping. `wiki_ls.py
|
||||
--titles` prints them one per line, which is what to paste.
|
||||
|
||||
## Endpoints, for when a script is not enough
|
||||
|
||||
Reach for `/tea:use` and `tea api` directly only for what has no script — a
|
||||
delete, or a page's history.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| list | `GET repos/{owner}/{repo}/wiki/pages` |
|
||||
| read | `GET repos/{owner}/{repo}/wiki/page/{sub_url}` |
|
||||
| create | `POST repos/{owner}/{repo}/wiki/new` — `{title, content_base64, message}` |
|
||||
| edit | `PATCH repos/{owner}/{repo}/wiki/page/{sub_url}` — same body |
|
||||
| delete | `DELETE repos/{owner}/{repo}/wiki/page/{sub_url}` |
|
||||
| history | `GET repos/{owner}/{repo}/wiki/revisions/{sub_url}` |
|
||||
|
||||
The `tea` CLI has no wiki subcommand. `tea api` is the only route.
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
wiki_ls.py — list what is actually in a wiki. One call, no bodies.
|
||||
|
||||
Cheap enough to run before a pull: it tells you what titles exist, which is the
|
||||
only thing a prefix filter can be built from, and it shows the `sub_url` Gitea
|
||||
settled on for each — worth a look the first time a title contains a dash or a
|
||||
slash, because the escaping is not what anyone guesses.
|
||||
|
||||
wiki_ls.py
|
||||
wiki_ls.py --prefix "Simple Chains"
|
||||
wiki_ls.py --repo other/repo --titles
|
||||
|
||||
Usage:
|
||||
wiki_ls.py [--repo owner/repo] [--prefix TITLE] [--titles] [--urls]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _HERE)
|
||||
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
|
||||
import _gitea # noqa: E402
|
||||
import wikimap # noqa: E402
|
||||
|
||||
|
||||
def cell(v):
|
||||
return (str(v or "").strip().replace("|", "\\|")) or "—"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
|
||||
ap.add_argument("--prefix", default="", help="only titles at or under this")
|
||||
ap.add_argument("--titles", action="store_true",
|
||||
help="print one title per line and nothing else")
|
||||
ap.add_argument("--urls", action="store_true", help="add the browser URL")
|
||||
a = ap.parse_args()
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(a.repo)
|
||||
slug = _gitea.repo_slug(login, a.repo)
|
||||
|
||||
listing = _gitea.paginate(login, "%s/wiki/pages" % base)
|
||||
if not isinstance(listing, list):
|
||||
_gitea.die("unexpected listing from %s/wiki/pages" % base)
|
||||
|
||||
rows = sorted((p for p in listing
|
||||
if wikimap.matches_prefix(p.get("title") or "", a.prefix)),
|
||||
key=lambda p: p.get("title") or "")
|
||||
if not rows:
|
||||
print("no page at or under %r in %s" % (a.prefix, slug) if a.prefix
|
||||
else "%s has no wiki pages" % slug)
|
||||
return 0
|
||||
|
||||
if a.titles:
|
||||
for p in rows:
|
||||
print(p.get("title") or "")
|
||||
return 0
|
||||
|
||||
head = ["title", "sub_url", "updated", "by"] + (["url"] if a.urls else [])
|
||||
print("| %s |" % " | ".join(head))
|
||||
print("|%s|" % "|".join("---" for _ in head))
|
||||
for p in rows:
|
||||
c = (p.get("last_commit") or {}).get("author") or {}
|
||||
row = [cell(p.get("title")), "`%s`" % cell(p.get("sub_url")),
|
||||
cell((c.get("date") or "")[:10]), cell(c.get("name"))]
|
||||
if a.urls:
|
||||
row.append(cell(p.get("html_url")))
|
||||
print("| %s |" % " | ".join(row))
|
||||
print("\n%d page(s) in %s" % (len(rows), slug))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
wiki_pull.py — fetch wiki pages into a local space.
|
||||
|
||||
wiki_pull.py the whole wiki
|
||||
wiki_pull.py --prefix "Simple Chains" a page and everything under it
|
||||
wiki_pull.py --repo other/repo --space docs from elsewhere, into a named space
|
||||
|
||||
The wiki is flat, so "a page and its children" is a prefix test on the title,
|
||||
run against one listing call. One GET per page follows. There is no tree
|
||||
endpoint to ask for a subtree, and no way to fetch bodies in bulk.
|
||||
|
||||
Pulling OVERWRITES the local body — a fetch, not a merge, the same stance the
|
||||
issue store takes. `sha` and `synced` tell you how old your copy is; re-pull
|
||||
when it matters. Nothing tracks drift.
|
||||
|
||||
Usage:
|
||||
wiki_pull.py [--repo owner/repo] [--prefix TITLE] [--space SPACE]
|
||||
[--dry-run] [--out DIR]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _HERE)
|
||||
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
|
||||
sys.path.insert(0, os.path.join(_HERE, "..", "..", "page", "scripts"))
|
||||
import _gitea # noqa: E402
|
||||
import page # noqa: E402
|
||||
import wikimap # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
|
||||
ap.add_argument("--prefix", default="", help="only titles at or under this")
|
||||
ap.add_argument("--space", help="local space (default: the owner/repo slug)")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||
a = ap.parse_args()
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(a.repo)
|
||||
slug = _gitea.repo_slug(login, a.repo)
|
||||
space = a.space or slug
|
||||
root = a.out or page.WIKI_ROOT
|
||||
space_dir = page.space_root(space, root)
|
||||
|
||||
listing = _gitea.paginate(login, "%s/wiki/pages" % base)
|
||||
if not isinstance(listing, list):
|
||||
_gitea.die("unexpected listing from %s/wiki/pages" % base)
|
||||
|
||||
wanted = [p for p in listing
|
||||
if wikimap.matches_prefix(p.get("title") or "", a.prefix)]
|
||||
if not wanted:
|
||||
if a.prefix:
|
||||
_gitea.die("no page at or under %r in %s (%d page(s) in the wiki)"
|
||||
% (a.prefix, slug, len(listing)))
|
||||
_gitea.die("%s has no wiki pages" % slug)
|
||||
|
||||
manifest = page.load_manifest(space, root)
|
||||
|
||||
# Two remote titles can land on one local path — Gitea keeps them apart with
|
||||
# its `.-` marker, a filesystem does not. Caught before anything is written,
|
||||
# because the failure mode otherwise is one page silently overwriting
|
||||
# another and the manifest pointing both entries at the survivor.
|
||||
seen = {}
|
||||
for p in wanted:
|
||||
seen.setdefault(page.path_for_title(p["title"]), []).append(p["title"])
|
||||
clashes = {k: v for k, v in seen.items() if len(v) > 1}
|
||||
for path, titles in sorted(clashes.items()):
|
||||
sys.stderr.write("collision: %s <- %s\n" % (path, " | ".join(titles)))
|
||||
|
||||
created = not os.path.isdir(space_dir)
|
||||
n = 0
|
||||
for p in sorted(wanted, key=lambda x: x.get("title") or ""):
|
||||
title = p["title"]
|
||||
relpath = page.path_for_title(title)
|
||||
if relpath in clashes:
|
||||
continue
|
||||
|
||||
if a.dry_run:
|
||||
print("%-44s %s" % (relpath, title))
|
||||
n += 1
|
||||
continue
|
||||
|
||||
full = _gitea.api(login, wikimap.page_endpoint(base, p["sub_url"]))
|
||||
if not isinstance(full, dict):
|
||||
_gitea.warn("could not read %r; skipped" % title)
|
||||
continue
|
||||
text = wikimap.decode(full)
|
||||
|
||||
dest = os.path.join(space_dir, relpath)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
# The prior entry is the base so `order` — a local decision the wiki
|
||||
# cannot hold — survives a pull.
|
||||
e = dict(manifest["pages"].get(relpath, {}))
|
||||
e.update(wikimap.from_payload(full))
|
||||
e["synced"] = _gitea.now_iso()
|
||||
# What is on disk is now exactly what is published, so push has nothing
|
||||
# to do until the file is edited.
|
||||
e["pushed"] = page.body_hash(text)
|
||||
manifest["pages"][relpath] = e
|
||||
print("%-44s %s" % (relpath, title))
|
||||
n += 1
|
||||
|
||||
if a.dry_run:
|
||||
print("\ndry run — %d page(s) would be written to %s" % (n, space_dir))
|
||||
return 1 if clashes else 0
|
||||
|
||||
page.save_manifest(manifest, root)
|
||||
if created:
|
||||
sys.stderr.write("created space %s\n" % space_dir)
|
||||
print("\n%d page(s) from %s -> %s" % (n, slug, space_dir))
|
||||
if clashes:
|
||||
sys.stderr.write("%d collision(s) skipped — rename them in the wiki\n"
|
||||
% len(clashes))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
wiki_push.py — publish a local space to a wiki.
|
||||
|
||||
wiki_push.py -m "Import the simple-chains discussion"
|
||||
wiki_push.py --prefix "Simple Chains/Ideas" -m "Rework B-04"
|
||||
wiki_push.py -m "Fix the send-timing table" Simple-Chains/Ideas/Send-timing.md
|
||||
|
||||
Every page in the selection is compared against `pushed` — the hash of what was
|
||||
last published — and only the ones that differ are sent. That is the whole of
|
||||
change detection: no timestamps, no drift model.
|
||||
|
||||
A page with no `sub_url` is created; a page with one is edited in place. The
|
||||
title comes from the manifest, never re-derived from the file, because sending
|
||||
a different title to the edit endpoint is a RENAME and leaves nothing behind at
|
||||
the old address.
|
||||
|
||||
Pushing is additive. A page deleted locally is NOT deleted in the wiki — the
|
||||
manifest simply stops mentioning it. Removing a published page is an explicit
|
||||
act; do it in the web UI or with a DELETE through /tea:use.
|
||||
|
||||
Usage:
|
||||
wiki_push.py -m MESSAGE [--space SPACE] [--repo owner/repo]
|
||||
[--prefix TITLE] [--dry-run] [--out DIR] [PATH-or-TITLE ...]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _HERE)
|
||||
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
|
||||
sys.path.insert(0, os.path.join(_HERE, "..", "..", "page", "scripts"))
|
||||
import _gitea # noqa: E402
|
||||
import page # noqa: E402
|
||||
import wikimap # noqa: E402
|
||||
|
||||
|
||||
def select(manifest, prefix, targets):
|
||||
"""The pages to consider, in tree order.
|
||||
|
||||
A positional argument matches a manifest path or a title, exactly. Exact
|
||||
because a near-miss that silently selects nothing is indistinguishable from
|
||||
a clean no-op run, and the operator finds out only when the page never
|
||||
appears."""
|
||||
pages = (page.children_of(manifest, prefix) if prefix
|
||||
else page.sorted_pages(manifest))
|
||||
if not targets:
|
||||
return pages, []
|
||||
want, chosen, hit = set(targets), [], set()
|
||||
for p, e in pages:
|
||||
if p in want or e.get("title") in want:
|
||||
chosen.append((p, e))
|
||||
hit.add(p if p in want else e.get("title"))
|
||||
return chosen, sorted(want - hit)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("targets", nargs="*", metavar="PATH-or-TITLE")
|
||||
ap.add_argument("-m", "--message", required=True,
|
||||
help="wiki commit message for this push")
|
||||
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
|
||||
ap.add_argument("--space", help="local space (default: the owner/repo slug)")
|
||||
ap.add_argument("--prefix", default="", help="only titles at or under this")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
|
||||
a = ap.parse_args()
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(a.repo)
|
||||
slug = _gitea.repo_slug(login, a.repo)
|
||||
space = a.space or slug
|
||||
root = a.out or page.WIKI_ROOT
|
||||
space_dir = page.space_root(space, root)
|
||||
if not os.path.isdir(space_dir):
|
||||
_gitea.die("no such space: %s (looked in %s). Import or pull first."
|
||||
% (space, space_dir))
|
||||
|
||||
manifest = page.load_manifest(space, root)
|
||||
if not manifest["pages"]:
|
||||
_gitea.die("space %s has no pages in its manifest" % space)
|
||||
|
||||
chosen, missing = select(manifest, a.prefix, a.targets)
|
||||
for t in missing:
|
||||
_gitea.warn("not in the manifest: %s" % t)
|
||||
if not chosen:
|
||||
_gitea.die("nothing selected")
|
||||
|
||||
created = updated = skipped = 0
|
||||
for relpath, e in chosen:
|
||||
title = e.get("title")
|
||||
full = os.path.join(space_dir, relpath)
|
||||
if not title:
|
||||
_gitea.warn("%s has no title in the manifest; skipped" % relpath)
|
||||
continue
|
||||
if not os.path.isfile(full):
|
||||
_gitea.warn("%s is in the manifest but not on disk; skipped" % relpath)
|
||||
continue
|
||||
with open(full, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
h = page.body_hash(text)
|
||||
|
||||
if e.get("sub_url") and h == e.get("pushed"):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
verb = "create" if not e.get("sub_url") else "update"
|
||||
print("%-7s %-44s %s" % (verb, relpath, title))
|
||||
if a.dry_run:
|
||||
created += verb == "create"
|
||||
updated += verb == "update"
|
||||
continue
|
||||
|
||||
if verb == "create":
|
||||
payload = wikimap.new_payload(title, text, a.message)
|
||||
got = _gitea.api(login, "%s/wiki/new" % base, method="POST",
|
||||
payload=payload, payload_name="wiki-new")
|
||||
else:
|
||||
payload = wikimap.edit_payload(title, text, a.message)
|
||||
got = _gitea.api(login, wikimap.page_endpoint(base, e["sub_url"]),
|
||||
method="PATCH", payload=payload,
|
||||
payload_name="wiki-edit")
|
||||
|
||||
if not isinstance(got, dict) or not got.get("sub_url"):
|
||||
_gitea.warn("%s: no page returned; the manifest is unchanged for it"
|
||||
% title)
|
||||
continue
|
||||
|
||||
# sub_url comes back from Gitea and is stored as given. It is the only
|
||||
# address this page has, and it is not something we could have computed.
|
||||
e.update(wikimap.from_payload(got))
|
||||
e["synced"] = _gitea.now_iso()
|
||||
e["pushed"] = h
|
||||
manifest["pages"][relpath] = e
|
||||
created += verb == "create"
|
||||
updated += verb == "update"
|
||||
|
||||
if a.dry_run:
|
||||
print("\ndry run — %d to create, %d to update, %d unchanged"
|
||||
% (created, updated, skipped))
|
||||
return 0
|
||||
|
||||
page.save_manifest(manifest, root)
|
||||
print("\n%d created, %d updated, %d unchanged -> %s wiki"
|
||||
% (created, updated, skipped, slug))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
wikimap.py — md <-> Gitea wiki JSON. The whole translation, and only the
|
||||
translation.
|
||||
|
||||
Pure functions: no network, no filesystem, no argparse. Give it a payload and
|
||||
it hands back a page; give it a page and it hands back a request body. That
|
||||
purity is the point — it can be reasoned about and tested without a Gitea
|
||||
anywhere, and it is the single file to open when the two representations
|
||||
disagree.
|
||||
|
||||
Direction of knowledge: this module imports the domain (page.py) and is
|
||||
imported by the transport's callers. The domain never imports this.
|
||||
|
||||
What crosses the boundary, and what does not:
|
||||
|
||||
domain Gitea note
|
||||
----------------------------------------------------------------------
|
||||
title title verbatim, both ways; `/` is the
|
||||
only hierarchy either side has
|
||||
path — local only; derived from the title
|
||||
order — local only; the wiki cannot sort
|
||||
body content_base64 base64, utf-8, verbatim
|
||||
— sub_url lands in the manifest as sub_url
|
||||
— last_commit.sha lands as sha
|
||||
— html_url lands as url
|
||||
|
||||
sub_url is the identity, and it is NOT derivable
|
||||
------------------------------------------------
|
||||
Gitea stores a wiki page as one flat file whose name it escapes from the title,
|
||||
and the escaping is not a mapping worth reimplementing:
|
||||
|
||||
"Abstract Issue" -> Abstract-Issue.md space -> dash
|
||||
"zz-probe/child" -> zz-probe%2Fchild.-.md / -> %2F, and a
|
||||
LITERAL dash forces a
|
||||
`.-` marker so the two
|
||||
cases stay distinct
|
||||
|
||||
Every rule there is Gitea's to change. So `sub_url` is read back from whatever
|
||||
the API returned and stored; it is never constructed here, and a caller that
|
||||
needs to address a page fetches the listing rather than guessing. Building one
|
||||
by hand is how you get a second page instead of an edit.
|
||||
|
||||
The wiki is flat, and only titles are structured
|
||||
------------------------------------------------
|
||||
There are no directories. A real subdirectory committed into the wiki's git
|
||||
repository is invisible to the API and to the web UI — a ghost file. All nesting
|
||||
lives in the title, which is why `page.py` treats `/` as its only separator.
|
||||
"""
|
||||
import base64
|
||||
|
||||
# A page's whole shape on the wire, for reference and for tests. Gitea also
|
||||
# returns `commit_count`, `sidebar` and `footer` on a single-page GET; none of
|
||||
# them describe the page itself, so none of them cross.
|
||||
WIRE_KEYS = ("title", "sub_url", "html_url", "content_base64", "last_commit")
|
||||
|
||||
|
||||
def decode(payload):
|
||||
"""content_base64 -> text. Missing content is "" and not None: a page that
|
||||
exists with an empty body is a real state, and the caller writing a file
|
||||
should not have to tell the two apart."""
|
||||
b = payload.get("content_base64") or ""
|
||||
return base64.b64decode(b).decode("utf-8", "replace") if b else ""
|
||||
|
||||
|
||||
def encode(text):
|
||||
return base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def from_payload(payload):
|
||||
"""Gitea JSON -> the manifest fields the wiki layer owns, plus the title
|
||||
the domain owns. The caller merges this into the existing entry so that
|
||||
domain keys it does not mention (`order`) survive."""
|
||||
commit = payload.get("last_commit") or {}
|
||||
author = commit.get("author") or {}
|
||||
return {
|
||||
"title": payload.get("title") or "",
|
||||
"sub_url": payload.get("sub_url") or "",
|
||||
"url": payload.get("html_url") or "",
|
||||
"sha": commit.get("sha") or "",
|
||||
"remote-updated": author.get("date") or "",
|
||||
}
|
||||
|
||||
|
||||
def new_payload(title, text, message):
|
||||
"""POST /repos/{owner}/{repo}/wiki/new.
|
||||
|
||||
`title` carries the hierarchy; Gitea derives the filename from it and
|
||||
returns the sub_url it settled on. `message` is the wiki commit message —
|
||||
the operator's words, not a generated one, because this is the only record
|
||||
of why a page changed."""
|
||||
return {"title": title, "content_base64": encode(text), "message": message}
|
||||
|
||||
|
||||
def edit_payload(title, text, message):
|
||||
"""PATCH /repos/{owner}/{repo}/wiki/page/{sub_url}.
|
||||
|
||||
The same shape as a create. Sending the unchanged title is a no-op; sending
|
||||
a different one is a RENAME, which moves the file and leaves nothing at the
|
||||
old sub_url — so callers pass the title from the manifest unless the
|
||||
operator asked for a rename."""
|
||||
return {"title": title, "content_base64": encode(text), "message": message}
|
||||
|
||||
|
||||
def page_endpoint(base, sub_url):
|
||||
"""The address of one page. `sub_url` goes in verbatim — Gitea hands it
|
||||
back already escaped (`%2F` and all), and re-encoding it here would produce
|
||||
a path that resolves to nothing."""
|
||||
return "%s/wiki/page/%s" % (base, sub_url)
|
||||
|
||||
|
||||
def revisions_endpoint(base, sub_url):
|
||||
return "%s/wiki/revisions/%s" % (base, sub_url)
|
||||
|
||||
|
||||
def matches_prefix(title, prefix):
|
||||
"""Is this page at, or under, a title prefix?
|
||||
|
||||
The wiki being flat, "children" is exactly this test and nothing more:
|
||||
there is no tree to walk, only a naming convention to trust. An empty
|
||||
prefix matches everything."""
|
||||
if not prefix:
|
||||
return True
|
||||
return title == prefix or title.startswith(prefix + "/")
|
||||
@@ -1,522 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
How a directory of markdown becomes a page tree, and that the tree survives a
|
||||
round trip through the wiki layer's bookkeeping.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib unittest, no third-party anything. `skills/*/scripts/` are not packages,
|
||||
so the modules under test are imported by path.
|
||||
|
||||
Nothing here touches tmp/wiki/. The subprocess cases build a throwaway
|
||||
repository in a temp directory — a `.git` marker, a copy of both script layers,
|
||||
a directory of fixture artifacts — and run the real scripts inside it. That is
|
||||
the only honest way to test behaviour that depends on where a script is run
|
||||
from, and it keeps the developer's own cache out of the blast radius.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PAGE_SCRIPTS = os.path.join(REPO, "skills", "page", "scripts")
|
||||
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
|
||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||
|
||||
sys.path.insert(0, PAGE_SCRIPTS)
|
||||
sys.path.insert(0, WIKI_SCRIPTS)
|
||||
import page # noqa: E402
|
||||
import wikimap # noqa: E402
|
||||
|
||||
|
||||
# The fixture mirrors the shape a real discussion leaves behind: numbered files
|
||||
# for ordering, a `00-` file standing in for its directory, headings that no
|
||||
# mechanical rule could derive from the file names.
|
||||
FIXTURE = {
|
||||
"handoff.md": "# handoff — notification chains\n\nEntry point.\n",
|
||||
"ideas/00-intro.md": "# Ideas for chain business requirements\n\nFlat list.\n",
|
||||
"ideas/02-chain-core.md": "## Chain core\n\n- **B-01.** Something.\n",
|
||||
"ideas/01-relations.md": "## Relations\n\nHow they relate.\n",
|
||||
"questions/00-intro.md": "# Questions\n\nOpen questions.\n",
|
||||
"questions/03-q-01-do-we-know-the-participant.md":
|
||||
"## Q-01. Do We Know the Chain Participant by Name\n\n**Question.** …\n",
|
||||
"notes/plain.md": "No heading here, only prose.\n",
|
||||
}
|
||||
|
||||
|
||||
def build_artifacts(root):
|
||||
for rel, text in FIXTURE.items():
|
||||
p = os.path.join(root, rel.replace("/", os.sep))
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
return root
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# names, titles, order — pure
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestNames(unittest.TestCase):
|
||||
|
||||
def test_order_comes_from_a_numeric_prefix(self):
|
||||
self.assertEqual(page.order_of("02-chain-core.md"), 2)
|
||||
self.assertEqual(page.order_of("00-intro.md"), 0)
|
||||
self.assertIsNone(page.order_of("handoff.md"))
|
||||
|
||||
def test_zero_is_an_order_and_not_a_missing_one(self):
|
||||
"""`00-` means "this is the directory's own page", so the difference
|
||||
between 0 and None decides where a page lands in the tree."""
|
||||
self.assertIsNot(page.order_of("00-intro.md"), None)
|
||||
|
||||
def test_the_prefix_never_reaches_the_title(self):
|
||||
self.assertEqual(page.title_from_name("02-chain-core.md"), "Chain core")
|
||||
|
||||
def test_only_the_first_letter_is_raised(self):
|
||||
"""Title-casing would wreck every name that already knows how it is
|
||||
spelled."""
|
||||
self.assertEqual(page.title_from_name("sqlc-and-APNs.md"), "Sqlc and APNs")
|
||||
|
||||
def test_a_heading_beats_a_file_name(self):
|
||||
text = "## Q-01. Do We Know the Chain Participant by Name\n"
|
||||
self.assertEqual(page.title_from_body(text),
|
||||
"Q-01. Do We Know the Chain Participant by Name")
|
||||
|
||||
def test_only_the_first_heading_counts(self):
|
||||
self.assertEqual(page.title_from_body("# One\n\n## Two\n"), "One")
|
||||
|
||||
def test_a_heading_after_prose_is_a_section_not_a_name(self):
|
||||
self.assertIsNone(page.title_from_body("Prose first.\n\n# Late\n"))
|
||||
|
||||
def test_markup_is_stripped_from_a_title(self):
|
||||
"""A page list does not render markdown, so inline code in a heading is
|
||||
noise in the name."""
|
||||
self.assertEqual(page.sanitize_title("Inventory — `P-NN`"),
|
||||
"Inventory — P-NN")
|
||||
|
||||
def test_a_slash_in_a_heading_does_not_invent_hierarchy(self):
|
||||
self.assertEqual(page.sanitize_title("Send/receive timing"),
|
||||
"Send-receive timing")
|
||||
|
||||
|
||||
class TestPaths(unittest.TestCase):
|
||||
|
||||
def test_a_title_becomes_one_path_component_per_segment(self):
|
||||
self.assertEqual(page.path_for_title("Simple Chains/Ideas/Chain core"),
|
||||
os.path.join("Simple-Chains", "Ideas", "Chain-core.md"))
|
||||
|
||||
def test_shell_hostile_characters_leave_the_path_but_not_the_title(self):
|
||||
title = "Simple Chains/Don't send to this one"
|
||||
self.assertEqual(page.path_for_title(title),
|
||||
os.path.join("Simple-Chains", "Dont-send-to-this-one.md"))
|
||||
self.assertIn("'", title)
|
||||
|
||||
def test_an_empty_title_still_produces_a_file(self):
|
||||
self.assertEqual(page.path_for_title(""), "untitled.md")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# importing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestPlanImport(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.src = build_artifacts(os.path.join(self.tmp.name, "artifacts"))
|
||||
self.pages, self.collisions = page.plan_import(self.src, "Simple Chains")
|
||||
self.titles = {p["title"] for p in self.pages}
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_nothing_collides(self):
|
||||
self.assertEqual(self.collisions, [])
|
||||
|
||||
def test_an_order_zero_file_becomes_the_directorys_own_page(self):
|
||||
self.assertIn("Simple Chains/Ideas", self.titles)
|
||||
|
||||
def test_that_page_is_named_for_the_directory_not_its_heading(self):
|
||||
"""`ideas/00-intro.md` opens with "Ideas for chain business
|
||||
requirements". A child's title must extend its parent's exactly, and no
|
||||
child would ever be prefixed by that."""
|
||||
self.assertNotIn("Simple Chains/Ideas for chain business requirements",
|
||||
self.titles)
|
||||
|
||||
def test_every_child_extends_its_parents_title(self):
|
||||
self.assertIn("Simple Chains/Ideas/Chain core", self.titles)
|
||||
self.assertIn("Simple Chains/Questions/"
|
||||
"Q-01. Do We Know the Chain Participant by Name",
|
||||
self.titles)
|
||||
|
||||
def test_a_file_without_a_heading_falls_back_to_its_name(self):
|
||||
self.assertIn("Simple Chains/Notes/Plain", self.titles)
|
||||
|
||||
def test_the_prefix_hangs_everything_under_one_title(self):
|
||||
self.assertTrue(all(t.startswith("Simple Chains/") for t in self.titles))
|
||||
|
||||
def test_numeric_prefixes_order_siblings(self):
|
||||
ideas = [p for p in self.pages
|
||||
if p["title"].startswith("Simple Chains/Ideas/")]
|
||||
self.assertEqual([p["title"].split("/")[-1] for p in ideas],
|
||||
["Relations", "Chain core"])
|
||||
|
||||
def test_a_collision_is_reported_and_not_resolved(self):
|
||||
"""Two headings that sanitize to one path. Picking a winner is how a
|
||||
discussion loses a document."""
|
||||
d = os.path.join(self.tmp.name, "clash")
|
||||
os.makedirs(d)
|
||||
for name, heading in (("a.md", "# Send timing"), ("b.md", "# Send/timing")):
|
||||
with open(os.path.join(d, name), "w") as f:
|
||||
f.write(heading + "\n")
|
||||
_, collisions = page.plan_import(d)
|
||||
self.assertEqual(len(collisions), 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the index
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestIndex(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.m = page.blank_manifest("s")
|
||||
for title, order in (("Top", None),
|
||||
("Top/Ideas", 0),
|
||||
("Top/Ideas/Relations", 1),
|
||||
("Top/Ideas/Chain core", 2),
|
||||
("Top/Zeta", None),
|
||||
("Top/Parked/Decisions", None)):
|
||||
self.m["pages"][page.path_for_title(title)] = page.entry(title, order)
|
||||
|
||||
def test_nesting_follows_titles_not_manifest_path_order(self):
|
||||
"""On disk `Top/Zeta.md` sorts before `Top/Ideas/Chain-core.md`; in the
|
||||
hierarchy Zeta is a child and Chain core a grandchild."""
|
||||
body = page.render_index(self.m, "Top")
|
||||
lines = [l for l in body.splitlines() if l.strip().startswith("- ")
|
||||
or l.strip().startswith(" - ")]
|
||||
ideas = next(i for i, l in enumerate(lines) if "|Ideas]]" in l)
|
||||
core = next(i for i, l in enumerate(lines) if "Chain core]]" in l)
|
||||
zeta = next(i for i, l in enumerate(lines) if "|Zeta]]" in l)
|
||||
self.assertLess(ideas, core)
|
||||
self.assertLess(core, zeta)
|
||||
|
||||
def test_a_parent_with_no_page_still_holds_its_children(self):
|
||||
"""Nothing is published at `Top/Parked`; dropping it would hide
|
||||
Decisions entirely."""
|
||||
body = page.render_index(self.m, "Top")
|
||||
self.assertIn("- [[Top/Parked|Parked]]", body)
|
||||
self.assertIn(" - [[Top/Parked/Decisions|Decisions]]", body)
|
||||
|
||||
def test_an_unpublished_page_is_linked_by_wiki_syntax(self):
|
||||
self.assertIn("[[Top/Ideas|Ideas]]", page.render_index(self.m, "Top"))
|
||||
|
||||
def test_a_published_page_is_linked_by_its_sub_url(self):
|
||||
"""sub_url is the only address Gitea guarantees, and it appears only
|
||||
after a push — so rebuilding the index after publishing upgrades the
|
||||
links."""
|
||||
rel = page.path_for_title("Top/Ideas")
|
||||
self.m["pages"][rel]["sub_url"] = "Top%2FIdeas"
|
||||
self.assertIn("- [Ideas](Top%2FIdeas)", page.render_index(self.m, "Top"))
|
||||
|
||||
def test_the_prefix_itself_is_not_listed_inside_its_own_index(self):
|
||||
self.assertNotIn("|Top]]", page.render_index(self.m, "Top"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the manifest
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestManifest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_a_missing_manifest_loads_blank(self):
|
||||
m = page.load_manifest("a/b", self.tmp.name)
|
||||
self.assertEqual(m["pages"], {})
|
||||
|
||||
def test_wiki_bookkeeping_survives_a_round_trip(self):
|
||||
"""The domain never reads sub_url, and must never drop it either — a
|
||||
lost sub_url is a duplicate page on the next push."""
|
||||
m = page.blank_manifest("a/b")
|
||||
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X", pushed="deadbeef")
|
||||
page.save_manifest(m, self.tmp.name)
|
||||
back = page.load_manifest("a/b", self.tmp.name)
|
||||
self.assertEqual(back["pages"]["X.md"]["sub_url"], "X")
|
||||
self.assertEqual(back["pages"]["X.md"]["pushed"], "deadbeef")
|
||||
self.assertEqual(back["pages"]["X.md"]["order"], 1)
|
||||
|
||||
def test_domain_keys_are_written_first(self):
|
||||
"""The manifest lands in a diff on every sync; a readable one gets
|
||||
checked."""
|
||||
m = page.blank_manifest("a/b")
|
||||
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X")
|
||||
with open(page.save_manifest(m, self.tmp.name), encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
self.assertLess(raw.index('"title"'), raw.index('"sub_url"'))
|
||||
|
||||
def test_children_of_is_a_prefix_test_and_not_a_substring_one(self):
|
||||
m = page.blank_manifest("s")
|
||||
for t in ("Top", "Top/A", "Topaz", "Topaz/B"):
|
||||
m["pages"][page.path_for_title(t)] = page.entry(t)
|
||||
got = {e["title"] for _, e in page.children_of(m, "Top")}
|
||||
self.assertEqual(got, {"Top", "Top/A"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# md <-> wiki JSON
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestWikiMap(unittest.TestCase):
|
||||
|
||||
def test_a_body_survives_encode_and_decode(self):
|
||||
text = "# Заголовок — DC\n\n- [x] пункт\n"
|
||||
self.assertEqual(wikimap.decode({"content_base64": wikimap.encode(text)}),
|
||||
text)
|
||||
|
||||
def test_an_empty_page_decodes_to_an_empty_string(self):
|
||||
"""A page that exists with no body is a real state; the caller writing
|
||||
a file should not have to tell it from a missing key."""
|
||||
self.assertEqual(wikimap.decode({}), "")
|
||||
self.assertEqual(wikimap.decode({"content_base64": None}), "")
|
||||
|
||||
def test_from_payload_takes_the_address_gitea_returned(self):
|
||||
got = wikimap.from_payload({
|
||||
"title": "A/B", "sub_url": "A%2FB.-", "html_url": "https://x/A%2FB.-",
|
||||
"last_commit": {"sha": "abc", "author": {"date": "2026-08-10T11:15:39Z"}},
|
||||
})
|
||||
self.assertEqual(got["sub_url"], "A%2FB.-")
|
||||
self.assertEqual(got["sha"], "abc")
|
||||
self.assertEqual(got["remote-updated"], "2026-08-10T11:15:39Z")
|
||||
|
||||
def test_a_sub_url_goes_into_the_endpoint_verbatim(self):
|
||||
"""Gitea hands it back already escaped; re-encoding it produces a path
|
||||
that resolves to nothing."""
|
||||
self.assertEqual(
|
||||
wikimap.page_endpoint("repos/o/r", "A%2FB.-"),
|
||||
"repos/o/r/wiki/page/A%2FB.-")
|
||||
|
||||
def test_prefix_matching_needs_a_separator(self):
|
||||
self.assertTrue(wikimap.matches_prefix("Top", "Top"))
|
||||
self.assertTrue(wikimap.matches_prefix("Top/A", "Top"))
|
||||
self.assertFalse(wikimap.matches_prefix("Topaz", "Top"))
|
||||
|
||||
def test_an_empty_prefix_matches_everything(self):
|
||||
self.assertTrue(wikimap.matches_prefix("anything", ""))
|
||||
|
||||
def test_a_payload_carries_the_operators_message(self):
|
||||
p = wikimap.new_payload("A/B", "body", "why it changed")
|
||||
self.assertEqual(p["message"], "why it changed")
|
||||
self.assertEqual(wikimap.decode(p), "body")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the scripts, in a throwaway repository
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestImportScript(unittest.TestCase):
|
||||
"""The real scripts, run as subprocesses inside a scratch repo."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = self.tmp.name
|
||||
os.makedirs(os.path.join(self.root, ".git"))
|
||||
# auth is in the list because the transport resolves the login pin
|
||||
# through skills/auth/scripts/pin.py — one search order, one module.
|
||||
for layer in ("page", "wiki", "sync", "auth"):
|
||||
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
||||
os.path.join(self.root, "skills", layer, "scripts"),
|
||||
ignore=shutil.ignore_patterns("__pycache__"))
|
||||
self.src = build_artifacts(os.path.join(self.root, "artifacts"))
|
||||
self.scripts = os.path.join(self.root, "skills", "page", "scripts")
|
||||
self.space = os.path.join(self.root, "tmp", "wiki", "s")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def run_script(self, name, *args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, os.path.join(self.scripts, name)] + list(args),
|
||||
capture_output=True, text=True, cwd=cwd or self.root)
|
||||
|
||||
def manifest(self):
|
||||
with open(os.path.join(self.space, ".pages.json"), encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def do_import(self, *extra):
|
||||
return self.run_script("page_import.py", "--from", self.src,
|
||||
"--space", "s", "--prefix", "Top", *extra)
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
r = self.do_import("--dry-run")
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertFalse(os.path.exists(self.space))
|
||||
|
||||
def test_import_writes_the_tree_and_the_manifest(self):
|
||||
self.assertEqual(self.do_import().returncode, 0)
|
||||
self.assertTrue(os.path.isfile(
|
||||
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
|
||||
self.assertIn("Top/Ideas/Chain core",
|
||||
{e["title"] for e in self.manifest()["pages"].values()})
|
||||
|
||||
def test_creating_a_space_is_announced(self):
|
||||
"""Nothing creates a store as a silent side effect of a write — that is
|
||||
how a typo in --space makes a second one nobody notices."""
|
||||
self.assertIn("created space", self.do_import().stderr)
|
||||
|
||||
def test_the_cache_is_found_from_a_subdirectory(self):
|
||||
"""The anchor is the script's own location, not cwd. A `cd` outlives
|
||||
the command that ran it."""
|
||||
self.do_import()
|
||||
deep = os.path.join(self.src, "ideas")
|
||||
r = self.run_script("page_ls.py", "--space", "s", cwd=deep)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertIn("Chain core", r.stdout)
|
||||
|
||||
def test_a_reimport_keeps_the_title_and_the_wiki_bookkeeping(self):
|
||||
self.do_import()
|
||||
m = self.manifest()
|
||||
rel = "Top/Ideas/Chain-core.md"
|
||||
m["pages"][rel]["sub_url"] = "Top%2FIdeas%2FChain-core"
|
||||
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
||||
json.dump(m, f)
|
||||
|
||||
# The heading changes. Without the manifest that would rename a
|
||||
# published page, which does not rename it — it publishes a second one.
|
||||
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
|
||||
f.write("## A completely different heading\n\nchanged\n")
|
||||
self.do_import()
|
||||
|
||||
after = self.manifest()["pages"][rel]
|
||||
self.assertEqual(after["title"], "Top/Ideas/Chain core")
|
||||
self.assertEqual(after["sub_url"], "Top%2FIdeas%2FChain-core")
|
||||
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
|
||||
self.assertIn("A completely different heading", f.read())
|
||||
|
||||
def test_retitle_moves_the_page_and_keeps_its_address(self):
|
||||
"""A retitle changes the path, so the entry has to be found by source.
|
||||
Found by path it would look new, and the next push would publish a
|
||||
duplicate beside the page it was meant to rename."""
|
||||
self.do_import()
|
||||
m = self.manifest()
|
||||
m["pages"]["Top/Ideas/Chain-core.md"]["sub_url"] = "Top%2FIdeas%2FChain-core"
|
||||
m["pages"]["Top/Ideas/Chain-core.md"]["pushed"] = "deadbeef"
|
||||
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
||||
json.dump(m, f)
|
||||
|
||||
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
|
||||
f.write("## Chain core, renamed\n")
|
||||
self.do_import("--retitle")
|
||||
|
||||
pages = self.manifest()["pages"]
|
||||
self.assertNotIn("Top/Ideas/Chain-core.md", pages)
|
||||
moved = pages["Top/Ideas/Chain-core-renamed.md"]
|
||||
self.assertEqual(moved["title"], "Top/Ideas/Chain core, renamed")
|
||||
self.assertEqual(moved["sub_url"], "Top%2FIdeas%2FChain-core")
|
||||
self.assertFalse(os.path.exists(
|
||||
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
|
||||
|
||||
def test_a_rename_makes_the_next_push_send_the_page(self):
|
||||
"""The body can be byte-identical after a rename, and push decides by
|
||||
body hash alone — so a stale `pushed` would skip the rename forever."""
|
||||
self.do_import()
|
||||
m = self.manifest()
|
||||
rel = "Top/Ideas/Chain-core.md"
|
||||
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
|
||||
body = f.read()
|
||||
m["pages"][rel]["sub_url"] = "x"
|
||||
m["pages"][rel]["pushed"] = __import__("hashlib").sha1(
|
||||
body.encode()).hexdigest()
|
||||
with open(os.path.join(self.space, ".pages.json"), "w") as f:
|
||||
json.dump(m, f)
|
||||
|
||||
src = os.path.join(self.src, "ideas", "02-chain-core.md")
|
||||
with open(src, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
with open(src, "w") as f:
|
||||
f.write(text.replace("## Chain core", "## Chain core renamed"))
|
||||
self.do_import("--retitle")
|
||||
|
||||
moved = self.manifest()["pages"]["Top/Ideas/Chain-core-renamed.md"]
|
||||
self.assertNotIn("pushed", moved)
|
||||
|
||||
def test_ls_reports_an_unpublished_page_as_local(self):
|
||||
self.do_import()
|
||||
r = self.run_script("page_ls.py", "--space", "s")
|
||||
self.assertIn("local", r.stdout)
|
||||
self.assertNotIn("synced", r.stdout)
|
||||
|
||||
def test_ls_distinguishes_a_missing_space_from_an_empty_one(self):
|
||||
r = self.run_script("page_ls.py", "--space", "nope")
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertIn("no such space", r.stderr)
|
||||
|
||||
def test_index_is_written_as_an_ordinary_page(self):
|
||||
self.do_import()
|
||||
r = self.run_script("page_index.py", "--space", "s", "--prefix", "Top")
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertIn("Top.md", self.manifest()["pages"])
|
||||
with open(os.path.join(self.space, "Top.md"), encoding="utf-8") as f:
|
||||
body = f.read()
|
||||
self.assertIn("- [[Top/Ideas|Ideas]]", body)
|
||||
self.assertIn(" - [[Top/Ideas/Chain core|Chain core]]", body)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the layering rule, mechanically
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestLayering(unittest.TestCase):
|
||||
|
||||
def test_the_page_layer_is_stdlib_only(self):
|
||||
"""skills/page must keep working with skills/wiki deleted — so no
|
||||
transport, and above all no subprocess, in the domain layer."""
|
||||
imported = set()
|
||||
for name in sorted(os.listdir(PAGE_SCRIPTS)):
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
|
||||
for line in f:
|
||||
if line.startswith(("import ", "from ")):
|
||||
imported.add(line.split()[1].split(".")[0])
|
||||
foreign = imported - {"page"} - sys.stdlib_module_names
|
||||
self.assertEqual(foreign, set(),
|
||||
"non-stdlib import in the page layer: %s"
|
||||
% ", ".join(sorted(foreign)))
|
||||
self.assertNotIn("subprocess", imported)
|
||||
|
||||
def test_the_page_layer_never_mentions_a_tracker(self):
|
||||
"""A sub_url, a login, an HTTP verb in skills/page means the concept is
|
||||
in the wrong layer."""
|
||||
banned = ("tea api", "_gitea", "GITEA_LOGIN", "content_base64")
|
||||
for name in sorted(os.listdir(PAGE_SCRIPTS)):
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
|
||||
body = f.read()
|
||||
for word in banned:
|
||||
self.assertNotIn(word, body,
|
||||
"%s mentions %r" % (name, word))
|
||||
|
||||
def test_wikimap_is_pure(self):
|
||||
"""The translation layer holds no transport and no I/O: give it a
|
||||
payload, get a page; give it a page, get a request body. Checked on the
|
||||
imports, not on the prose — the docstring names the things it refuses
|
||||
to do."""
|
||||
with open(os.path.join(WIKI_SCRIPTS, "wikimap.py")) as f:
|
||||
imported = {line.split()[1].split(".")[0] for line in f
|
||||
if line.startswith(("import ", "from "))}
|
||||
self.assertEqual(imported, {"base64"},
|
||||
"wikimap.py imports more than the translation needs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user