Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83f73c5cea | |||
| 23f78beafb | |||
| 5a8bd1c299 | |||
| 81119a3bd9 | |||
| f97ac952f7 | |||
| 493a787940 | |||
| 7ab967bfaa | |||
| edb2f5a627 | |||
| 62027db76c | |||
| 9479babfe9 | |||
| e330a11e8f | |||
| bf0936526d | |||
| f7cffd7c48 | |||
| e6b4cf773c | |||
| 17567cd6a2 | |||
| f5977fa4fc | |||
| 74a0e3b173 | |||
| bb964d5a55 | |||
| 40016e06f2 | |||
| 2a8da81359 | |||
| c18b16a14b | |||
| cfbd6e5ddb | |||
| 1d7abc11ae | |||
| a2e9a88186 | |||
| 627df76812 | |||
| 2f82b501bd | |||
| 9679e2c000 | |||
| 596cf853e8 | |||
| bea3735e47 | |||
| 2ac301550e | |||
| e629d14585 | |||
| 1815d91cdf | |||
| 257c547e22 | |||
| d4c43464e5 | |||
| 47f53a7edc | |||
| 484da64621 | |||
| 31f7c39155 | |||
| f230f98f35 | |||
| b72f619fda | |||
| 62c8ff976d | |||
| fb862554ed | |||
| 6d01ead245 | |||
| 0cf4baa429 | |||
| d8bd927f1d | |||
| 9234d8004f |
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"name": "tea",
|
||||
"name": "claude-skills",
|
||||
"owner": {
|
||||
"name": "naudachu"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "tea",
|
||||
"source": "./",
|
||||
"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, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login."
|
||||
"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,77 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project goals
|
||||
|
||||
1. **Unify and systematize issue workflow** for the development team with
|
||||
minimal context usage. Issue operations are wrapped in scripts so agents
|
||||
spend tokens on the task, not on re-deriving commands and formats.
|
||||
2. **Keep the tracker out of the work.** An issue is a unit of work first and a
|
||||
Gitea row second. The two are separate layers, and the first one does not
|
||||
know the second exists.
|
||||
3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of
|
||||
direct ad-hoc calls wherever possible. Scripts give deterministic,
|
||||
reviewable behavior; the `tea-guard` hook enforces that every `tea`
|
||||
invocation runs under the operator-pinned login.
|
||||
|
||||
## Layers
|
||||
|
||||
The hard rule of this repo. Knowledge flows one way only:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
▲ offline — no tracker, no network, stdlib imports only
|
||||
│ imports
|
||||
skills/sync BRIDGE map.py md <-> Gitea JSON, pure functions, no I/O
|
||||
_gitea.py login pin, tea api, pagination, filters
|
||||
skills/use REFERENCE tea CLI docs for everything that is not an issue
|
||||
skills/auth IDENTITY pin the login the whole tracker side runs under
|
||||
```
|
||||
|
||||
`skills/issue` never imports from `skills/sync`. Delete `skills/sync` and the
|
||||
domain layer keeps working. The check is mechanical — every import under
|
||||
`skills/issue/scripts/` is stdlib, and `subprocess` is not among them:
|
||||
|
||||
```bash
|
||||
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
||||
```
|
||||
|
||||
If a tracker concept (issue number, login, HTTP call, label color) shows up in
|
||||
the domain layer, it is in the wrong place.
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
||||
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
||||
- `references/format.md` — canonical issue format; single source of truth
|
||||
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
||||
taxonomy, dependency graph
|
||||
- `scripts/issue_new.py` — create a local issue from its type template
|
||||
- `scripts/issue_check.py` — validate against the format
|
||||
- `scripts/issue_tree.py` — draw the dependency graph
|
||||
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
||||
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
||||
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
|
||||
label ids, the remote-id map
|
||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
||||
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
||||
(`/tea:use`); `references/tea/` holds the command docs
|
||||
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
||||
that don't use the pinned login; `agents-sync` keeps every directory canonical
|
||||
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
||||
|
||||
## Local issue store
|
||||
|
||||
`tmp/issues/` (gitignored) is **the store, not a cache of Gitea**. One flat
|
||||
markdown file per issue, named by its slug, with one metadata field per line so
|
||||
plain grep works without a parser.
|
||||
|
||||
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
|
||||
Numbers live in the `gitea:` field.
|
||||
- `origin: local` is a durable state. An issue that never leaves this machine is
|
||||
complete and valid, not a draft.
|
||||
- Pushing is additive: the file is never deleted, it gains `gitea:` / `url:` /
|
||||
`synced:`.
|
||||
- Pulling overwrites the body — a fetch, not a merge.
|
||||
- No drift tracking. `synced:` tells you how old your copy is; re-pull when it
|
||||
matters.
|
||||
@@ -1,139 +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-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
|
||||
```
|
||||
|
||||
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
|
||||
/plugin marketplace add https://git.noodles.cam/claude-skills/marketplace.git
|
||||
```
|
||||
|
||||
Already have a local clone? Point at the directory instead:
|
||||
Working from a local clone? Point at the directory instead:
|
||||
|
||||
```
|
||||
/plugin marketplace add /path/to/tea
|
||||
/plugin marketplace add /path/to/marketplace
|
||||
```
|
||||
|
||||
2. Install the plugin:
|
||||
Then install what you need:
|
||||
|
||||
```
|
||||
/plugin install tea@tea
|
||||
/plugin install tea@claude-skills
|
||||
/plugin install tdl@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.
|
||||
Use `/plugin` to enable, disable, or update them 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.
|
||||
## What ships here
|
||||
|
||||
## First use
|
||||
| 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 |
|
||||
|
||||
Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to `.claude/settings.local.json` and takes effect immediately — no restart needed.
|
||||
|
||||
```
|
||||
/tea:auth
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Project layout
|
||||
## Layout
|
||||
|
||||
```
|
||||
.claude-plugin/
|
||||
plugin.json plugin manifest
|
||||
marketplace.json marketplace catalog (makes `/plugin install` work)
|
||||
hooks/
|
||||
hooks.json registers the PreToolUse hook
|
||||
tea-guard.sh the guard (Python 3, no deps)
|
||||
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/
|
||||
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
|
||||
issue_new.py create a local issue from its type template
|
||||
issue_check.py validate against the format
|
||||
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 (additive; never deletes)
|
||||
remote.py discovery listing to stdout
|
||||
comment.py post or edit a comment
|
||||
use/ /tea:use — tea CLI reference (non-issue entities)
|
||||
SKILL.md
|
||||
references/tea/ command docs
|
||||
```
|
||||
|
||||
## Local issue store
|
||||
A plugin's root is its directory under `plugins/`, so `${CLAUDE_PLUGIN_ROOT}`
|
||||
resolves inside it and every path a plugin uses stays relative to itself.
|
||||
Adding a plugin means adding a directory here plus one entry in
|
||||
`marketplace.json` — nothing else in the repo needs to know about it.
|
||||
|
||||
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
|
||||
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
|
||||
a parser.
|
||||
## Development
|
||||
|
||||
It is **the store, not a cache of Gitea**:
|
||||
`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 durable state. An issue that never leaves your machine is
|
||||
complete and valid.
|
||||
- Pushing is additive — the file gains `gitea:` / `url:` / `synced:` and stays
|
||||
put. Pulling overwrites the body: a fetch, not a merge.
|
||||
- Nothing tracks drift. `synced:` tells you how old your copy is.
|
||||
```
|
||||
cd plugins/tea && python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tea-guard — PreToolUse(Bash) hook for the `tea` plugin.
|
||||
|
||||
Enforces, deterministically, the one rule prose cannot: every `tea` command
|
||||
that touches Gitea runs under the login the OPERATOR pinned — never one Claude
|
||||
chose. It does this by *resolving and rewriting* the command rather than just
|
||||
checking it:
|
||||
|
||||
Claude must write: tea ... --login "$GITEA_LOGIN" ...
|
||||
The guard rewrites: tea ... --login <operator-pinned-login> ...
|
||||
|
||||
The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call
|
||||
time — from the FILE, not the environment — so a freshly pinned login works in
|
||||
the same session with no restart.
|
||||
|
||||
Rules:
|
||||
- not a `tea` command ............................. allow (passthrough)
|
||||
- tea logins list/ls, tea --version/--help ........ allow (no identity used)
|
||||
- no --login / -l ................................. BLOCK
|
||||
- --login <literal> or --login "$OTHER_VAR" ....... BLOCK (Claude may not pick)
|
||||
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
|
||||
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
|
||||
|
||||
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
|
||||
rewrite; exit 2 + stderr to block.
|
||||
"""
|
||||
import sys, os, re, json, shlex
|
||||
|
||||
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
|
||||
|
||||
|
||||
def block(msg):
|
||||
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def allow_passthrough():
|
||||
# exit 0 with no stdout → tool runs unchanged
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def rewrite(tool_input, new_cmd, note):
|
||||
updated = dict(tool_input)
|
||||
updated["command"] = new_cmd
|
||||
print(json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"updatedInput": updated,
|
||||
"additionalContext": note,
|
||||
}
|
||||
}))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def find_pin(start_dir):
|
||||
"""Walk up from start_dir; return (login, path) from the first
|
||||
.claude/settings.local.json that carries a non-empty env.GITEA_LOGIN."""
|
||||
try:
|
||||
d = os.path.abspath(start_dir or ".")
|
||||
except Exception:
|
||||
return None, None
|
||||
while True:
|
||||
p = os.path.join(d, ".claude", "settings.local.json")
|
||||
if os.path.isfile(p):
|
||||
try:
|
||||
with open(p) as f:
|
||||
data = json.load(f)
|
||||
v = (data.get("env") or {}).get("GITEA_LOGIN")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip(), p
|
||||
except Exception:
|
||||
pass
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None, None
|
||||
d = parent
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception:
|
||||
# Can't parse the hook payload — fail open for non-tea safety, but we
|
||||
# can't even read the command, so don't block arbitrary Bash.
|
||||
allow_passthrough()
|
||||
|
||||
tool_input = payload.get("tool_input") or {}
|
||||
cmd = tool_input.get("command") or ""
|
||||
|
||||
# Not a `tea` invocation → not our concern.
|
||||
if not re.search(r'(^|[;&|(]|\s)tea(\s|$)', cmd):
|
||||
allow_passthrough()
|
||||
|
||||
# Whitelist: login enumeration + meta. No identity is used; /tea:auth
|
||||
# needs `tea logins list` while no pin exists yet.
|
||||
if re.search(r'tea\s+(logins\s+(list|ls)|--version|-v|--help|help)(\s|$)', cmd):
|
||||
allow_passthrough()
|
||||
|
||||
# Locate --login / -l and its value (logins never contain spaces).
|
||||
m = re.search(r'(--login|(?<![\w-])-l)(\s+|=)(\S+)', cmd)
|
||||
if not m:
|
||||
block('every `tea` command must include --login "$GITEA_LOGIN" '
|
||||
'(the guard substitutes the operator-pinned login). '
|
||||
'Run /tea:auth if no login is pinned.')
|
||||
|
||||
raw_val = m.group(3)
|
||||
inner = raw_val
|
||||
for q in ('"', "'"):
|
||||
if len(inner) >= 2 and inner[0] == q and inner[-1] == q:
|
||||
inner = inner[1:-1]
|
||||
break
|
||||
|
||||
if inner not in PLACEHOLDERS:
|
||||
block('do not name the login yourself (got `%s`). Write exactly '
|
||||
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
|
||||
'the operator pinned via /tea:auth. This prevents acting under '
|
||||
'the wrong identity.' % raw_val)
|
||||
|
||||
start = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
||||
pin, src = find_pin(start)
|
||||
if not pin:
|
||||
block('no login is pinned. Run /tea:auth to choose one (writes '
|
||||
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
||||
'the file at call time, so it takes effect with no restart.')
|
||||
|
||||
new_cmd = cmd[:m.start(3)] + shlex.quote(pin) + cmd[m.end(3):]
|
||||
rewrite(tool_input, new_cmd,
|
||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (pin, src))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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`
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.",
|
||||
"version": "2.0.0",
|
||||
"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"
|
||||
},
|
||||
@@ -0,0 +1,228 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project goals
|
||||
|
||||
1. **Unify and systematize issue workflow** for the development team with
|
||||
minimal context usage. Issue operations are wrapped in scripts so agents
|
||||
spend tokens on the task, not on re-deriving commands and formats.
|
||||
2. **Keep the tracker out of the work.** An issue is a unit of work first and a
|
||||
Gitea row second. The two are separate layers, and the first one does not
|
||||
know the second exists.
|
||||
3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of
|
||||
direct ad-hoc calls wherever possible. Scripts give deterministic,
|
||||
reviewable behavior; the `tea-guard` hook enforces that every `tea`
|
||||
invocation runs under the operator-pinned login.
|
||||
|
||||
## Layers
|
||||
|
||||
The hard rule of this repo. One domain, one bridge, one transport, and
|
||||
knowledge flows one way only:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||
▲ offline — no tracker, no network, stdlib imports only
|
||||
│ imports
|
||||
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
|
||||
_gitea.py tea api, pagination, filters, payloads
|
||||
│ imports
|
||||
▼
|
||||
skills/auth IDENTITY pin the login the whole tracker side runs under
|
||||
▲ pin.py where the pin is and how it is found —
|
||||
│ imports imported by _gitea.py AND by hooks/tea-guard.sh
|
||||
hooks/tea-guard so `tea` and the scripts cannot disagree
|
||||
|
||||
skills/use REFERENCE tea CLI docs for everything that is not an issue
|
||||
▲
|
||||
│ calls
|
||||
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
|
||||
```
|
||||
|
||||
The domain never imports its bridge: delete `skills/sync` and issues still
|
||||
work. The check is mechanical — every import under the domain's `scripts/` is
|
||||
stdlib, and `subprocess` is not among them:
|
||||
|
||||
```bash
|
||||
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
|
||||
```
|
||||
|
||||
If a tracker concept (issue number, login, HTTP call, label color) shows up in
|
||||
the domain layer, it is in the wrong place.
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
||||
- `scripts/pin.py` — the one written copy of the pin's location and search
|
||||
order (see "The login pin" below); stdlib, no subprocess, no network
|
||||
- `skills/issue` — issues as units of work (`/tea:issue`), entirely offline
|
||||
- `references/format.md` — canonical issue format; single source of truth
|
||||
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
|
||||
taxonomy, dependency graph, body checkboxes
|
||||
- `scripts/issue_new.py` — create a local issue from its type template
|
||||
- `scripts/issue_check.py` — validate against the format
|
||||
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
|
||||
substring, changing exactly one character of the file
|
||||
- `scripts/issue_tree.py` — draw the dependency graph
|
||||
- `scripts/issue_evict.py` — remove closed issues from the store; never an
|
||||
`origin: local` one
|
||||
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
|
||||
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
|
||||
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
|
||||
- `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids,
|
||||
the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py`
|
||||
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
|
||||
- `scripts/close.py` — the state field, both ways; explicit ids only
|
||||
- `scripts/evict.py` — refresh `state:` from Gitea, then hand the decision to
|
||||
the domain's `issue_evict.run`
|
||||
- `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a
|
||||
repository; reads the domain taxonomy, never the store
|
||||
- `skills/use` — `tea` CLI reference for everything that is not an issue
|
||||
(`/tea:use`); `references/tea/` holds the command docs
|
||||
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
|
||||
returns a compact receipt. Delegate batches (bulk pull, push a named set,
|
||||
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
|
||||
and no `Write`, may not `--force`, and may not decide what an issue says.
|
||||
Delegating a single call costs more than running it inline — the win is the
|
||||
loop, the retry, and the error triage.
|
||||
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
|
||||
that don't use the pinned login (resolving it through `auth/pin.py`);
|
||||
`agents-sync` keeps every directory canonical (`AGENTS.md` real file,
|
||||
`CLAUDE.md` symlink to it)
|
||||
- `tests/` — stdlib `unittest`, no third-party anything
|
||||
|
||||
## The login pin
|
||||
|
||||
`<project root>/.claude/settings.local.json` → `env.GITEA_LOGIN`, written by
|
||||
`/tea:auth` and read at call time. **The search order is written once, in
|
||||
`skills/auth/scripts/pin.py`**, and both callers import it: the transport
|
||||
(`_gitea.require_login`) and the `tea-guard` hook. Neither spells the path or
|
||||
the walk itself, and a test asserts they don't.
|
||||
|
||||
Start directories, first hit wins: `$CLAUDE_PROJECT_DIR`, then a hint the
|
||||
caller supplies (the hook passes the Bash payload's `cwd`; a script passes
|
||||
nothing), then the current directory. Each one is searched up its parent chain,
|
||||
and then — only if that found nothing — up the parent chain of the **main
|
||||
working tree of any linked worktree** met on the way, reached by reading
|
||||
`gitdir:` out of a `.git` *file* and following `commondir`.
|
||||
|
||||
**The pin is not resolved from `__file__`, and that asymmetry with
|
||||
`issue.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.**
|
||||
Where an installation keeps its files is a fact about the installation; whose
|
||||
login a project runs under is a fact about the project. A plugin installed
|
||||
outside any repository and pointed at somebody else's tree must not answer the
|
||||
second question from its own directory. So the search runs from the working
|
||||
directory upward — and reaches a worktree's main checkout by asking git.
|
||||
|
||||
Two failures this replaces, both worth remembering: a git worktree is a
|
||||
*sibling* of the main checkout, so the untracked pin is not on its parent chain
|
||||
and the whole sync layer died there while `tea` in the same directory worked;
|
||||
and the cure it invited — `/tea:auth` inside the worktree — writes a second
|
||||
settings file into a directory that is deleted with the worktree.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Plain `unittest`; no pytest, no dependencies — the scripts under test are
|
||||
stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
|
||||
packages, so a test that needs the domain module imports it with
|
||||
`sys.path.insert`.
|
||||
|
||||
**A test never touches `tmp/issues/` or `tmp/payload/`.** Anything that needs a
|
||||
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
|
||||
`.git` marker, a copy of the script layers, fixture issues — and runs the real
|
||||
scripts inside it as subprocesses. That is the only way to test behavior that depends on where a
|
||||
script is run from, and it keeps the developer's own store out of the blast
|
||||
radius.
|
||||
|
||||
`tmp/payload/` is in that list because `_gitea.PAYLOAD_ROOT` is resolved once,
|
||||
from the module's own location: a test that stubs the transport *below* `api()`
|
||||
— at `subprocess`, to exercise a non-2xx — reaches the real write. Such a test
|
||||
patches `PAYLOAD_ROOT` to its own temp directory too.
|
||||
|
||||
## Local issue store
|
||||
|
||||
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
|
||||
store.** An `origin: local` issue lives here and nowhere else — this file *is*
|
||||
the issue, and losing it loses the work. Anything with `origin: gitea` is a
|
||||
**cache**: the tracker has it, this copy is a working copy, and it is deleted
|
||||
the moment a push confirms the tracker is up to date.
|
||||
|
||||
One flat markdown file per issue, named by its slug, with one metadata field per
|
||||
line so plain grep works without a parser.
|
||||
|
||||
- **The path is `<repo root>/tmp/issues`, resolved from `issue.py`'s own
|
||||
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
|
||||
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
|
||||
whatever directory it is run from. An explicit `--out` overrides it and is
|
||||
used exactly as typed; a relative `--out` stays relative to cwd.
|
||||
- Nothing creates the store as a side effect of a write. Readers distinguish
|
||||
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
|
||||
and they say so on stderr.
|
||||
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
|
||||
Numbers live in the `gitea:` field.
|
||||
- `origin: local` is a complete state, not a draft: an issue that never leaves
|
||||
this machine is valid and finished. It is not a *durable* state, though —
|
||||
pushing ends it, and the local file goes with it.
|
||||
- **A successful push deletes the local file** (`<id>.md` and
|
||||
`<id>.comments.md`), and prints the number and URL the issue now lives at.
|
||||
`--update` too: one rule, no exception. What is in the store is what has not
|
||||
left. Get it back with `pull.py <n>` — which brings its blockers back with it:
|
||||
a pull returns the unit of work, not one row of it. `--no-deps` narrows it to
|
||||
the one issue, and the cost of the default is in `pull.py`'s docstring.
|
||||
- Deletion happens only after a confirmed tracker response and only after
|
||||
`.remote.json` has been written. Network down, non-2xx, an answer that does
|
||||
not carry the right number: the file stays and the run stops. A never-pushed
|
||||
`origin: local` issue is never touched by any of this.
|
||||
- The slug survives the round trip because it goes up in the body as
|
||||
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
|
||||
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
|
||||
fresh clone, another machine — the file comes back under the same name and
|
||||
every `depends:` that points at it still resolves.
|
||||
- `.remote.json` is therefore no longer "an index over the files": it is the
|
||||
local number → slug ledger, its entries outlive the files they name, and
|
||||
nothing prunes them. It is still recoverable — from the markers in Gitea, not
|
||||
from the files. **Eviction does not prune it either**, for the same reason a
|
||||
push does not: an evicted issue is in exactly the state a pushed one is.
|
||||
- **A closed issue is evicted, not archived.** `issue_evict.py` removes
|
||||
`<id>.md` and every sidecar under that slug for anything that is `state:
|
||||
closed` **and** carries an `origin:` naming a tracker, then rebuilds
|
||||
`INDEX.md`. `--dry-run` prints and writes nothing. **`origin: local` is never
|
||||
evicted, in any state, not even when named on the command line** — that file
|
||||
*is* the issue and nothing can fetch it back.
|
||||
- **Eviction lives in the domain** (`skills/issue/scripts/issue_evict.py`),
|
||||
because its two inputs — `state:` and `origin:` — are domain fields and the
|
||||
answer is already on disk. No network, no login, no `tea`.
|
||||
`skills/sync/scripts/evict.py` is the bridge form: it refreshes `state:` from
|
||||
the tracker first (a local `state:` is only as fresh as the last pull) and then
|
||||
calls `issue_evict.run`. One implementation of "what may be evicted", in the
|
||||
layer that owns the fields it reads. Same gate as push, one step earlier: a
|
||||
failed or unconfirmed tracker answer evicts nothing at all.
|
||||
- **Pull by number fetches an issue in any state — a number is a number.** An
|
||||
address is not a query: `pull.py 42` puts a closed issue on disk exactly as it
|
||||
always has, and so does `#42`, `owner/repo#42`, or its URL. Only filter mode
|
||||
(`--milestone`, `--label`, `-q`) leaves closed issues out. Eviction does not
|
||||
revoke this: a closed issue pulled after a cleanup lands on disk again, and
|
||||
that is the tracker answering what it was asked, not a regression. Evict it
|
||||
again when you are done with it.
|
||||
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
|
||||
issue comes back at all.
|
||||
- No drift tracking, and now nothing to track: there is no second copy to
|
||||
diverge from. `synced:` tells you how old your working copy is.
|
||||
|
||||
## Request payloads
|
||||
|
||||
`tmp/payload/` (gitignored) holds the JSON bodies `tea api -d @file` was given,
|
||||
one file per named request, kept after the call for a retry or a post-mortem.
|
||||
It is **not a store and holds nobody's only copy** — deleting it costs nothing.
|
||||
|
||||
- One directory for every caller, resolved from `_gitea.py`'s own location, so
|
||||
which command wrote a body does not change where it landed. `_gitea.api` takes no directory argument; that it once did
|
||||
is exactly how a label bootstrap came to create `tmp/issues/`.
|
||||
- It is created lazily, by the first write of a run, and only then: a `--dry-run`
|
||||
or a run with nothing to send leaves no directory behind.
|
||||
- **A scratchpad may never sit inside a store.** Store contents are the thing
|
||||
being tracked; request bodies are debris of the transport. When the two share
|
||||
a path, an operation that touches no issue at all still materializes the issue
|
||||
store, and the operator's `ls tmp/issues` starts lying about what exists.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: tea-runner
|
||||
description: Executes the tea plugin's scripts and reports back a compact receipt. Use for the mechanical half of tracker work — bulk pulls, pushing issues the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
|
||||
tools: Bash, Read, Grep, Glob, Skill
|
||||
model: haiku
|
||||
---
|
||||
|
||||
# tea-runner — the execution layer
|
||||
|
||||
You run this project's issue scripts and hand back a short receipt. You are the
|
||||
fourth layer of the plugin, below the three that carry meaning:
|
||||
|
||||
```
|
||||
skills/issue DOMAIN what an issue is
|
||||
skills/sync BRIDGE md <-> Gitea, over the wire
|
||||
skills/use REFERENCE tea CLI docs
|
||||
▲
|
||||
│ calls
|
||||
tea-runner EXECUTION runs the scripts, reports the result
|
||||
```
|
||||
|
||||
Knowledge still flows one way. You call those layers; nothing in them knows you
|
||||
exist. **You hold no opinion about content.** Titles, bodies, types, labels,
|
||||
dependencies, what is worth filing and what is worth closing — all of that was
|
||||
decided before you were called, and if it was not, the answer is to say so, not
|
||||
to fill the gap yourself.
|
||||
|
||||
## Where the commands come from
|
||||
|
||||
Load the skill, do not remember the flags:
|
||||
|
||||
- `/tea:sync` — `pull.py`, `push.py`, `comment.py`, `close.py`, `remote.py`,
|
||||
`labels.py`, `evict.py`
|
||||
- `/tea:issue` — `issue_check.py`, `issue_tree.py`, `issue_index.py`,
|
||||
`issue_new.py`, `issue_ac.py`, `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
|
||||
truth for the script surface; a flag you recall from another session is a
|
||||
guess. If the skill does not document a flag, it does not exist — report that
|
||||
instead of trying it.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **No raw `tea`.** Every tracker call goes through a script in
|
||||
`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
|
||||
write files; you do not. If a task needs a body edited or a metadata field
|
||||
changed by hand, stop and say which file and which field. `issue_ac.py` is
|
||||
the one script that touches a body, and it changes a single character: tick
|
||||
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` publishes to a tracker
|
||||
other people read, **and it deletes the local file on success** — so a
|
||||
widened set is not an over-share, it is somebody else's working copy gone.
|
||||
Run it with the ids, titles, or filter the caller named. Never widen the
|
||||
set, never run a bare `push.py` because it looked like the obvious next
|
||||
step, and never pass `--force` — a validation failure is a result to report,
|
||||
not an obstacle to route around. Report the number and URL `push.py`
|
||||
printed; that is now the only address the issue has.
|
||||
4. **Close only the ids the caller named.** Closing is a script now
|
||||
(`close.py`), so it is yours to run — under the same discipline as push: the
|
||||
ids the caller named, and no others. Never widen the set, never infer that
|
||||
an issue is finished because its checkboxes are ticked or its branch is
|
||||
merged; whether work is done is a judgement about content, and content is
|
||||
never yours. `--reopen` is the same rule backwards. **Retitling stays
|
||||
forbidden**, and deleting anything on a tracker is never yours either.
|
||||
|
||||
Two local deletions are allowed, both only when the caller asked for them:
|
||||
push's own, on the issue you were told to push, and eviction
|
||||
(`issue_evict.py` / `evict.py`) of closed issues. Run eviction with
|
||||
`--dry-run` first and report what it named; never widen the set past what
|
||||
the caller said. It refuses to touch an `origin: local` issue by itself —
|
||||
that is the script's guarantee, not your judgement, and it is not a reason
|
||||
to point it at a store nobody asked you to clean.
|
||||
5. **One retry, maximum.** A command that fails twice is a finding. Do not
|
||||
permute flags looking for one that works.
|
||||
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
|
||||
issue body back into your report. The scripts print compact output by
|
||||
design; the caller reads the files it needs from disk.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Load the skill you need.
|
||||
2. Run the commands. Prefer one filtered call over a loop —
|
||||
`pull.py --milestone 6` is one request per 50 issues, `pull.py 41 42 43…`
|
||||
is one per issue.
|
||||
3. If a command exits non-zero, capture the last lines of stderr and stop that
|
||||
branch. Keep going on independent branches.
|
||||
4. Report.
|
||||
|
||||
## Report format
|
||||
|
||||
Your final message is the return value. Keep it under ~20 lines. No preamble,
|
||||
no restatement of the request, no advice about what to do next.
|
||||
|
||||
```
|
||||
ran:
|
||||
pull.py --milestone 6 --state all ok 7 issues, 3 threads
|
||||
issue_index.py ok INDEX.md rebuilt
|
||||
push.py wire-sqlc-appclick FAIL exit 1
|
||||
|
||||
touched: tmp/issues/{a,b,c}.md, tmp/issues/INDEX.md
|
||||
|
||||
failed: push.py wire-sqlc-appclick
|
||||
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
|
||||
|
||||
blocked: none
|
||||
```
|
||||
|
||||
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
|
||||
- `touched` — paths only. Never contents.
|
||||
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
|
||||
the cause. Quote it exactly; do not paraphrase an error.
|
||||
- `blocked` — what you refused to decide, phrased as the question the caller
|
||||
has to answer. `none` when there is nothing.
|
||||
|
||||
## Known stops
|
||||
|
||||
Report these and halt; none of them is yours to resolve.
|
||||
|
||||
| Condition | Report |
|
||||
|---|---|
|
||||
| no login pinned (`tea-guard` blocks, or a script points at `/tea:auth`) | `blocked: no pinned login — operator must run /tea:auth` |
|
||||
| `issue_check.py` errors before a push | the validator's own lines, verbatim |
|
||||
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
|
||||
| a milestone or label does not exist in the repo | the script prints the real ones — pass that list through |
|
||||
| a script asks for a decision (type, label, `--force`) | `blocked:` with the question |
|
||||
| `close.py` is refused by Gitea because the issue is still blocked | the tracker's own line, and the blocker's number; the caller decides |
|
||||
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()
|
||||
@@ -32,13 +32,37 @@ So:
|
||||
3. **One login:** propose it; confirm before writing.
|
||||
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
|
||||
`url` so the operator's choice is unambiguous. Never decide for them.
|
||||
5. Merge the chosen name into `.claude/settings.local.json` under `env`
|
||||
(do not clobber other keys):
|
||||
5. Merge the chosen name into the **project root's**
|
||||
`.claude/settings.local.json` under `env` (do not clobber other keys):
|
||||
```json
|
||||
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
|
||||
```
|
||||
**In a git worktree, write it to the main checkout, never to the worktree.**
|
||||
A worktree is deleted when the branch is done, taking a pin written into it
|
||||
with it, and one repository with two pins is one repository with two
|
||||
identities. Both the guard and the scripts already reach the main checkout's
|
||||
pin from inside any worktree — so there is nothing to pin a second time.
|
||||
`git rev-parse --path-format=absolute --git-common-dir` names the `.git` to
|
||||
write beside.
|
||||
6. Done — it is live. The guard resolves the pin from the file on the next
|
||||
`tea` call; no restart needed. Tell the operator which login is now pinned.
|
||||
`tea` call; no restart needed. Tell the operator which login is now pinned,
|
||||
and which file it went in.
|
||||
|
||||
## Where the pin is looked for
|
||||
|
||||
One search order, written once in `scripts/pin.py` and imported by both the
|
||||
`tea-guard` hook and the sync transport — they cannot disagree about a
|
||||
directory, and a test asserts neither keeps a copy of the walk.
|
||||
|
||||
`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's
|
||||
`cwd`), then the current directory. Each is searched up its parent chain; only
|
||||
if that finds nothing does the search cross into the main working tree of a
|
||||
linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory
|
||||
is never a source — a plugin pointed at somebody else's project must take the
|
||||
identity from that project, not from where it happens to be installed.
|
||||
|
||||
If a script reports "no login pinned", that is the honest answer: nothing was
|
||||
found anywhere on that order. Pin one — at the project root.
|
||||
|
||||
## Identity-safety rules
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pin.py — where the operator's Gitea login pin is, and how it is found.
|
||||
|
||||
**The search order lives here and nowhere else.** The `tea-guard` hook imports
|
||||
this module; so does the transport every sync script runs on. Two
|
||||
copies of the order is exactly how a git worktree came to have a working hook
|
||||
and a dead transport in the same directory: `tea` resolved the login, the
|
||||
scripts said "no login pinned", and the error told the operator to pin what was
|
||||
already pinned.
|
||||
|
||||
Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse
|
||||
hook runs before every Bash call and must not fork a process to answer this.
|
||||
|
||||
The pin is a file the OPERATOR owns and `/tea:auth` writes:
|
||||
|
||||
<project root>/.claude/settings.local.json -> env.GITEA_LOGIN
|
||||
|
||||
## Search order
|
||||
|
||||
Start directories, in order, first hit wins:
|
||||
|
||||
1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set
|
||||
2. an explicit hint the hook passes the Bash tool's cwd; scripts pass
|
||||
nothing and go straight to 3
|
||||
3. the current directory
|
||||
|
||||
Each start directory is searched the same way:
|
||||
|
||||
a. up the parent chain, from the directory itself to the filesystem root
|
||||
b. then, for each LINKED WORKTREE seen on that chain, up the parent chain
|
||||
of that repository's main working tree
|
||||
|
||||
(b) is the whole point of this module. A worktree is a *sibling* of the main
|
||||
checkout, not a descendant, so `.claude/settings.local.json` — untracked, and
|
||||
therefore only ever in the main checkout — is not on the parent chain of (a).
|
||||
Git knows the two trees are one repository: a worktree's `.git` is a FILE
|
||||
holding `gitdir: <path>`, and `<path>/commondir` points back at the shared
|
||||
`.git`. `git rev-parse --git-common-dir` answers the same question by forking;
|
||||
this reads the files.
|
||||
|
||||
## Why the search does not start at __file__
|
||||
|
||||
Deliberate asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which
|
||||
*are* anchored on their own module's location. Two different questions:
|
||||
|
||||
where does this installation keep its files a fact about the plugin
|
||||
whose login does this project run under a fact about the project
|
||||
|
||||
A plugin installed outside any repository and pointed at somebody else's tree
|
||||
must answer the second one from the tree it was pointed at. Anchoring the pin
|
||||
on `__file__` would make the plugin's own directory an identity source, which
|
||||
is how a checkout ends up acting under a login nobody chose for it. So the
|
||||
search runs from the working directory upward — and reaches a worktree's main
|
||||
checkout by asking git, not by walking somewhere else.
|
||||
|
||||
Finding nothing is a real answer: `(None, None)` means there is no pin, and the
|
||||
caller says so. This module never guesses a login.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
SETTINGS_PARTS = (".claude", "settings.local.json")
|
||||
ENV_KEY = "GITEA_LOGIN"
|
||||
PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR"
|
||||
|
||||
|
||||
def settings_path(root):
|
||||
"""The pin file for a project root. The one place this path is spelled."""
|
||||
return os.path.join(root, *SETTINGS_PARTS)
|
||||
|
||||
|
||||
def read_pin(path):
|
||||
"""The login in a settings file, or None.
|
||||
|
||||
Unreadable, not JSON, no `env`, empty string — all the same answer. A
|
||||
broken file is not a login and is not worth a traceback in a hook."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
value = (json.load(f).get("env") or {}).get(ENV_KEY)
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def parents(start):
|
||||
"""`start` and every ancestor of it, up to the filesystem root."""
|
||||
d = os.path.abspath(start)
|
||||
while True:
|
||||
yield d
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return
|
||||
d = parent
|
||||
|
||||
|
||||
def gitdir_of(d):
|
||||
"""The private git directory `d/.git` points at, or None.
|
||||
|
||||
Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a
|
||||
directory and there is nothing to follow."""
|
||||
p = os.path.join(d, ".git")
|
||||
if not os.path.isfile(p):
|
||||
return None
|
||||
try:
|
||||
with open(p) as f:
|
||||
head = f.read(4096)
|
||||
except OSError:
|
||||
return None
|
||||
for line in head.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("gitdir:"):
|
||||
target = line[len("gitdir:"):].strip()
|
||||
if not target:
|
||||
return None
|
||||
if not os.path.isabs(target):
|
||||
target = os.path.join(d, target)
|
||||
return os.path.abspath(target)
|
||||
return None
|
||||
|
||||
|
||||
def main_worktree(d):
|
||||
"""If `d` is a linked worktree, the main working tree of its repository.
|
||||
|
||||
`<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir`
|
||||
file holds a path to `<main>/.git`; the main working tree is its parent.
|
||||
The `.git` basename check keeps this to worktrees: a submodule's `.git`
|
||||
is a pointer too, but it points into `<super>/.git/modules/…`, and the
|
||||
tree it belongs to is already on the parent chain."""
|
||||
gitdir = gitdir_of(d)
|
||||
if not gitdir or not os.path.isdir(gitdir):
|
||||
return None
|
||||
common = gitdir
|
||||
marker = os.path.join(gitdir, "commondir")
|
||||
if os.path.isfile(marker):
|
||||
try:
|
||||
with open(marker) as f:
|
||||
rel = f.read().strip()
|
||||
except OSError:
|
||||
rel = ""
|
||||
if rel:
|
||||
common = os.path.abspath(os.path.join(gitdir, rel))
|
||||
if os.path.basename(common) != ".git":
|
||||
return None
|
||||
root = os.path.dirname(common)
|
||||
if root and os.path.isdir(root) and root != os.path.abspath(d):
|
||||
return root
|
||||
return None
|
||||
|
||||
|
||||
def search(start):
|
||||
"""(login, path) for one start directory: the parent chain, then the main
|
||||
checkout of any worktree met on it. (None, None) when there is no pin.
|
||||
|
||||
The chain comes first and always wins, so the worktree branch can only
|
||||
ever find a pin that walking up would not have found at all."""
|
||||
hops = []
|
||||
for d in parents(start):
|
||||
login = read_pin(settings_path(d))
|
||||
if login:
|
||||
return login, settings_path(d)
|
||||
root = main_worktree(d)
|
||||
if root and root not in hops:
|
||||
hops.append(root)
|
||||
for root in hops:
|
||||
# One level of indirection, never two: a main checkout is not itself a
|
||||
# linked worktree, so this loop cannot chain and cannot cycle.
|
||||
for d in parents(root):
|
||||
login = read_pin(settings_path(d))
|
||||
if login:
|
||||
return login, settings_path(d)
|
||||
return None, None
|
||||
|
||||
|
||||
def start_dirs(hint=None):
|
||||
"""The ordered, deduplicated start directories.
|
||||
|
||||
`hint` is the caller's own idea of where the work is happening — the hook
|
||||
passes the `cwd` from its payload, which is the directory the Bash command
|
||||
will actually run in. A script has no payload and passes nothing."""
|
||||
try:
|
||||
cwd = os.getcwd()
|
||||
except OSError: # cwd deleted out from under us
|
||||
cwd = None
|
||||
out = []
|
||||
for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd):
|
||||
if not d:
|
||||
continue
|
||||
d = os.path.abspath(d)
|
||||
if d not in out:
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def find_pin(hint=None):
|
||||
"""(login, path) for the first start directory that has a pin, else
|
||||
(None, None). The entry point; everything above is its parts."""
|
||||
for start in start_dirs(hint):
|
||||
login, path = search(start)
|
||||
if login:
|
||||
return login, path
|
||||
return None, None
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: issue
|
||||
description: Work with this project's issues as units of work — create, read, grep, validate, and walk their dependency graph. Entirely offline; issues are local markdown files and need no tracker. Load when the user asks to file/create an issue, read or find issues, check an issue against the format, or see what depends on what. For pushing to or pulling from Gitea, load /tea:sync instead.
|
||||
---
|
||||
|
||||
# /tea:issue — issues as units of work
|
||||
|
||||
An issue is a markdown file in `tmp/issues/`. This skill covers everything you
|
||||
do **with** an issue: writing one, reading one, checking it against the
|
||||
canonical format, and walking the dependency graph.
|
||||
|
||||
**Nothing here touches the network.** No `tea`, no Gitea, no login. An issue
|
||||
that lives only on this machine is a first-class issue, not a draft waiting to
|
||||
be uploaded. Synchronizing with a tracker is a separate, optional layer —
|
||||
`/tea:sync`.
|
||||
|
||||
Read [`references/format.md`](references/format.md) before creating or editing
|
||||
an issue. It is the single source of truth for identity, metadata, types,
|
||||
labels, templates, and language rules.
|
||||
|
||||
## Identity: the slug
|
||||
|
||||
The file name is the id and the id is a slug — `tmp/issues/wire-sqlc-appclick.md`.
|
||||
It never changes, not when the title changes and not when the issue is pushed
|
||||
somewhere. Tracker numbers live in a metadata field (`gitea: owner/repo#42`),
|
||||
never in a file name and never in `depends:`.
|
||||
|
||||
Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to
|
||||
issues by id.
|
||||
|
||||
## Scripts
|
||||
|
||||
All offline, all in `<skill-base-dir>/scripts/`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `issue_new.py --type T --title "…"` | create `tmp/issues/<slug>.md` from the type's template |
|
||||
| `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 |
|
||||
|
||||
```
|
||||
tmp/issues/INDEX.md table of every issue — read this first
|
||||
tmp/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
|
||||
tmp/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
|
||||
tmp/issues/tree-<id>.md saved graph (issue_tree.py --write)
|
||||
```
|
||||
|
||||
## Where the store is
|
||||
|
||||
`<repo root>/tmp/issues` — **not** `tmp/issues` 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 store no matter which
|
||||
directory you run them from, and a `cd` earlier in the session changes nothing.
|
||||
|
||||
`--out` overrides that and is taken **literally**: an absolute path is used as
|
||||
given, a relative one stays relative to the current directory. Nothing rewrites
|
||||
what you typed.
|
||||
|
||||
Two things follow, and both are deliberate:
|
||||
|
||||
- A store that is not there reports `does not exist`; a store with no issues in
|
||||
it reports `is empty`. They are different problems.
|
||||
- No script conjures a store as a side effect of writing. Only `issue_new.py`
|
||||
creates one — the first issue in a fresh checkout — and it says so on stderr.
|
||||
|
||||
## Reading: grep, don't parse
|
||||
|
||||
Metadata is one field per line with inline lists precisely so plain `grep`
|
||||
works. `INDEX.md` first, then the files:
|
||||
|
||||
```bash
|
||||
grep -l 'labels:.*type/bug' tmp/issues/*.md # all bugs
|
||||
grep -l 'origin: local' tmp/issues/*.md # never pushed anywhere
|
||||
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
|
||||
grep -A3 '## Acceptance criteria' tmp/issues/wire-*.md
|
||||
grep -c '^- \[ \]' tmp/issues/wire-sqlc-appclick.md # open checkboxes
|
||||
```
|
||||
|
||||
Read whole files only for the issues the task actually needs.
|
||||
|
||||
## Creating an issue
|
||||
|
||||
1. **Read the format**: [`references/format.md`](references/format.md).
|
||||
2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a
|
||||
container for several issues with one business value), or `draft` (an idea
|
||||
not ready for work). If it is not obvious from the request, ask the user
|
||||
(one question).
|
||||
3. **Scaffold it:**
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_new.py \
|
||||
--type task --title "Wire sqlc into the appclick repo layer" \
|
||||
--label tech/sql --label comp/appclick --depends migrate-schema
|
||||
```
|
||||
English imperative title with no type prefix; `--depends` takes ids.
|
||||
4. **Fill the sections** with Edit — every section of the template present and
|
||||
in order, headers English, prose Russian. `## Spec` gets a repo path, a URL,
|
||||
or the literal `none`; ask the user if you cannot determine which.
|
||||
5. **Check it:**
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
||||
```
|
||||
|
||||
One file = one issue. Several related issues = several files, linked through
|
||||
`depends:`.
|
||||
|
||||
The issue is now real and complete. Publishing it to Gitea is a separate
|
||||
decision — `/tea:sync` — and does not change the file's status here.
|
||||
|
||||
## Editing an issue
|
||||
|
||||
Edit the file. Change `state:` to close it, edit `labels:`, add ids to
|
||||
`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh
|
||||
the table. Checkboxes are the exception — use `issue_ac.py`, below.
|
||||
|
||||
If the issue is synced (`origin: gitea`), the file is a working copy: your edit
|
||||
is local until you run `push.py --update` from `/tea:sync`, and that push
|
||||
**deletes the file** once Gitea has it. Closing one of those is `close.py` from
|
||||
`/tea:sync` — it moves the state on both sides in a single run; editing
|
||||
`state:` here alone would only ever tell this machine. Nothing tracks drift, and with one copy
|
||||
at a time there is little to track — a file that is still here has not been
|
||||
pushed. Get it back with `pull.py <n>`; the slug does not change.
|
||||
|
||||
## Ticking checkboxes
|
||||
|
||||
A checkbox is the one part of a body that is **state** and not prose, so it has
|
||||
a command of its own. Never rewrite a body just to tick a box: the rewrite
|
||||
re-flows lines and re-words sentences, and the issue's diff swells around a
|
||||
change that means one character.
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check 3
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check "регресс"
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --uncheck 3
|
||||
```
|
||||
|
||||
With no flag it prints the numbered list with each item's state, grouped by the
|
||||
heading the item sits under. `--check` / `--uncheck` take that number or a
|
||||
substring of the item's text (case-insensitive).
|
||||
|
||||
- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A
|
||||
`type/feature` keeps its children as checkboxes under `## Issues`, and they
|
||||
are numbered in the same list. The script is named after the section most
|
||||
boxes live in, nothing more.
|
||||
- **A substring must match exactly one item.** Two matches is an error that
|
||||
lists them; pick by number instead. It never guesses.
|
||||
- **Exactly one character of the file changes.** Metadata, wording, wrapping
|
||||
and trailing whitespace all come back byte for byte, so `git diff` and the
|
||||
tracker's diff show the tick and nothing else.
|
||||
- Examples inside a ``` fence are markup, not state — they are skipped.
|
||||
- `INDEX.md` gains a `progress` column (`3/7`, blank when the issue has no
|
||||
boxes), recomputed from the body on every build and stored in no field.
|
||||
`issue_ac.py` rebuilds the index after a successful tick.
|
||||
|
||||
Getting the tick to the tracker is a separate step — `push.py --update` in
|
||||
`/tea:sync`.
|
||||
|
||||
## Writing a proper description
|
||||
|
||||
Issues get filed on the run — "comments aren't pulled", "the guard broke".
|
||||
That is a request, not a statement of work: no reproduction steps, no
|
||||
`path/file:line`, acceptance criteria nobody can check. Rewriting one into the
|
||||
canonical format is a procedure, not improvisation.
|
||||
|
||||
1. **Read the issue whole**, and everything it points at — the ids in
|
||||
`depends:`, the `## Spec` target, the files it names.
|
||||
2. **Determine the type and its template.** The `type/*` label selects one of
|
||||
the templates in [`references/format.md`](references/format.md), and that
|
||||
template's section list is the shape you are aiming at. If the label is
|
||||
missing or wrong, decide it now and fix `labels:`; promoting a `type/draft`
|
||||
to a concrete type is this same step.
|
||||
3. **Locate the anchor points in the code.** Grep the repo for every file,
|
||||
symbol, command, and error string the issue mentions, until you can name
|
||||
lines:
|
||||
```bash
|
||||
grep -rn 'GITEA_LOGIN' hooks/ skills/
|
||||
```
|
||||
Work that does not exist yet still has anchor points — the files the change
|
||||
will land in, and the ones that will call it.
|
||||
4. **Gather the missing context.** What has to be there when you are done:
|
||||
- code references in the `path/file.ext:line` form, for every place the
|
||||
change lands;
|
||||
- reproduction steps — exact commands and their real output (`type/bug`
|
||||
splits them across `## Steps to reproduce` / `## Expected` / `## Actual`);
|
||||
- acceptance criteria that are objectively checkable: a command that exits
|
||||
0, a file that exists, a section that is present — not aspirations;
|
||||
- a real value for `## Spec` — a repo path, a URL, or the literal `none`.
|
||||
|
||||
**A missing fact is either found in the repository or becomes a question to
|
||||
the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in
|
||||
`## Spec` as the legitimate answer it is — never a plausible-looking link.
|
||||
5. **Rewrite the sections** with Edit: every section of the template, in the
|
||||
template's order, English headers and Russian prose. Replace the body; do
|
||||
not append a second telling of the same issue below the old one.
|
||||
6. **Check it:**
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
||||
```
|
||||
Errors mean malformed, warnings mean the type's template is not fully
|
||||
filled in. Re-run `issue_index.py` if the labels changed.
|
||||
|
||||
The procedure is identical for `origin: local` and `origin: gitea` — it works
|
||||
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
|
||||
is prose for humans. `issue_check.py` warns when they disagree.
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_tree.py # all roots
|
||||
python3 <skill-base-dir>/scripts/issue_tree.py wire-sqlc-appclick --write
|
||||
```
|
||||
|
||||
A `type/feature` plus its children read as one document: draw the tree once for
|
||||
the shape, then grep the files.
|
||||
|
||||
## Layering rule
|
||||
|
||||
This skill must keep working with `skills/sync/` deleted. Every import under
|
||||
`scripts/` is stdlib, and `subprocess` is not among them:
|
||||
|
||||
```bash
|
||||
grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u
|
||||
```
|
||||
|
||||
If you find yourself wanting a tracker concept here — an issue number, a login,
|
||||
an HTTP call — it belongs in `/tea:sync`.
|
||||
@@ -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`.
|
||||
@@ -14,13 +13,22 @@ sync layer's business — see `/tea:sync`.
|
||||
An issue is one file, `tmp/issues/<id>.md`, and `id` is a slug: lowercase
|
||||
ASCII, digits, single dashes, derived from the title. **The slug is the
|
||||
identity.** It is stable for the life of the issue — a retitled issue keeps its
|
||||
slug, and an issue pushed to a tracker keeps it too. Tracker numbers are a
|
||||
foreign key stored in a field, never the name of anything.
|
||||
slug; an issue pushed to a tracker, deleted locally and fetched back a month
|
||||
later keeps it too. Tracker numbers are a foreign key stored in a field, never
|
||||
the name of anything.
|
||||
|
||||
```
|
||||
tmp/issues/wire-sqlc-appclick.md
|
||||
```
|
||||
|
||||
A slug never contains a dot, which is how the store tells an issue from the
|
||||
files parked beside it (`<id>.comments.md`).
|
||||
|
||||
Stability is a promise the format makes, so something has to keep it once the
|
||||
file is gone. That is the sync layer's problem and its answer is a marker in the
|
||||
body — see `/tea:sync`; the domain neither writes nor reads it, and it never
|
||||
appears in the file on disk.
|
||||
|
||||
## Metadata block
|
||||
|
||||
One field per line, lists inline, so plain `grep` works without a parser:
|
||||
@@ -34,6 +42,7 @@ assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
remote-updated: 2026-08-09T18:24:01Z
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
@@ -55,6 +64,7 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
||||
| `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 |
|
||||
| `url`, `synced`, `remote-updated`, `comments` | sync | bookkeeping |
|
||||
|
||||
Domain fields render first, in the order above; sync fields follow, sorted.
|
||||
@@ -65,8 +75,30 @@ sync layer's business — the domain carries `gitea:` and the rest through
|
||||
load/save verbatim and never reads them. That passthrough is why one file can
|
||||
represent a local issue and a synced one without a second format.
|
||||
|
||||
`origin: local` is a **durable state, not a pending one.** An issue that never
|
||||
leaves this machine is complete and valid. Pushing is optional and additive.
|
||||
`origin: local` is a **complete state, not a pending one.** An issue that never
|
||||
leaves this machine is valid and finished work; pushing it is optional and
|
||||
nothing here treats it as a draft.
|
||||
|
||||
It is not a *permanent* state, and it is what the file's fate depends on:
|
||||
|
||||
| `origin:` | what the file is | what a push does to it | what eviction does to it |
|
||||
|---|---|---|---|
|
||||
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
|
||||
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
|
||||
|
||||
**A successful push deletes `tmp/issues/<id>.md`** (and `<id>.comments.md`), on
|
||||
create and on `--update` alike. What is in the store is what has not left this
|
||||
machine; everything else is fetched again when it is needed. The rule, its
|
||||
safety conditions, and how the slug survives are `/tea:sync`'s to state.
|
||||
|
||||
**A closed issue is evicted from the store** by `issue_evict.py` — same trade,
|
||||
one condition more: the work is done *and* it exists somewhere else. An
|
||||
`origin: local` issue is never evicted, because there is nowhere to fetch it
|
||||
back from. The store is a working set, not an archive; `pull.py <n>` fetches a
|
||||
closed issue again whenever it is wanted.
|
||||
|
||||
The `id` never changes across that round trip, which is why `depends:` in other
|
||||
issues keeps working. That is the format's promise; the mechanism is not.
|
||||
|
||||
## Language rules
|
||||
|
||||
@@ -143,6 +175,11 @@ you — `issue_check.py` warns when the section names an id that `depends:` does
|
||||
not list. Omit the section when there are no dependencies; never write an empty
|
||||
one.
|
||||
|
||||
A `type/feature` container writes the same relation under `## Issues` instead
|
||||
(see the template below). Same direction, same rule: every id named there also
|
||||
belongs in that issue's `depends:`. The warning names whichever of the two
|
||||
sections the reference actually came from.
|
||||
|
||||
Draw the graph with `issue_tree.py`. The reverse direction is a grep:
|
||||
|
||||
```bash
|
||||
@@ -160,6 +197,13 @@ grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
|
||||
valid answer.
|
||||
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
|
||||
checkable condition, not an aspiration.
|
||||
- A checkbox is **item markup, not a property of one section**: `- [ ]`
|
||||
unticked, `- [x]` ticked, and it means the same under `## Issues` as under
|
||||
`## Acceptance criteria`. An item that wraps continues on an indented line
|
||||
and is still one item. A `- [ ]` inside a ``` code fence is an example of the
|
||||
markup, not state. Tick them with `issue_ac.py`, which reads the whole body
|
||||
on exactly these rules and rewrites one character; progress (`3/7`) is
|
||||
counted off the body and is never a metadata field.
|
||||
- Code references use the `path/file.ext:line` form; related issues by id.
|
||||
- Screenshots are allowed but their content must be duplicated as text — an
|
||||
LLM reading these files cannot see images.
|
||||
@@ -256,9 +300,31 @@ grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
|
||||
## Template: `type/feature`
|
||||
|
||||
A container: one unit of business value delivered by several child issues.
|
||||
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and link back
|
||||
via their `depends:`. Keep implementation detail in the children; the feature
|
||||
body stays at business level.
|
||||
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and know
|
||||
nothing about the container.
|
||||
|
||||
**The container depends on its children, never the reverse.** Every child id
|
||||
goes in the container's own `depends:` and, as prose, in its `## Issues`
|
||||
section; a child's `depends:` is for that child's real dependencies and must
|
||||
not point back at the container. Keep implementation detail in the children;
|
||||
the feature body stays at business level.
|
||||
|
||||
That direction is not a convention picked at random. "The container is closed
|
||||
when its children are closed" *is* a dependency relation. "This child belongs
|
||||
to that feature" is a membership relation, and membership has no place in a
|
||||
dependency graph. Pointed the other way the two rules contradict each other:
|
||||
the moment the container listed a child that already depended on it,
|
||||
`issue_check.py` would report `ERROR cycle`. With the edge going down, the
|
||||
graph reads as nesting — `issue_tree.py` draws the container as the root with
|
||||
its children beneath it — and the check is green.
|
||||
|
||||
So the container's metadata block carries the children:
|
||||
|
||||
```markdown
|
||||
depends: [wire-sqlc-appclick, add-pool-cfg]
|
||||
```
|
||||
|
||||
and its body repeats them for a human:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
@@ -272,7 +338,7 @@ body stays at business level.
|
||||
|
||||
## Issues
|
||||
- [ ] wire-sqlc-appclick — краткое описание части
|
||||
- [ ] …
|
||||
- [ ] add-pool-cfg — краткое описание части
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] все дочерние issues закрыты
|
||||
@@ -45,14 +45,76 @@ without a parser:
|
||||
grep -l 'labels:.*type/bug' tmp/issues/*.md
|
||||
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
|
||||
"""
|
||||
import collections
|
||||
import os
|
||||
import re
|
||||
|
||||
ISSUE_ROOT = os.path.join("tmp", "issues")
|
||||
# --------------------------------------------------------------------------
|
||||
# where the store lives
|
||||
# --------------------------------------------------------------------------
|
||||
# `<repo root>/tmp/issues`, absolute, resolved once at import.
|
||||
#
|
||||
# It used to be the relative `tmp/issues`, which made "the store" whatever
|
||||
# directory the shell happened to be standing in. One `cd` — and a `cd` outlives
|
||||
# the command that ran it — was enough for readers to report an empty store on a
|
||||
# full one and for writers to quietly build a second store beside the first.
|
||||
#
|
||||
# The anchor is THIS FILE, not the working directory. A script's own location is
|
||||
# a fact about the installation; cwd is a fact about the last `cd`. Walking up
|
||||
# from __file__ therefore hands every script in both layers the same answer no
|
||||
# matter where it is invoked from — including from inside tmp/issues itself.
|
||||
#
|
||||
# An explicit --out still wins over all of this, and is used exactly as typed: a
|
||||
# relative --out stays relative to cwd, because that is what the operator asked
|
||||
# for. There is no environment override; the store is where the repo is.
|
||||
|
||||
STORE_PARTS = ("tmp", "issues")
|
||||
|
||||
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||
# git; the agents-sync hook only ever puts one at a repository root.
|
||||
REPO_MARKERS = (".git", "AGENTS.md")
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def repo_root(start):
|
||||
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None.
|
||||
|
||||
Markers, not a fixed number of `..` hops: how deep this file sits below the
|
||||
root is an implementation detail of the repo layout, and the layout is not
|
||||
a promise."""
|
||||
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 issue store.
|
||||
|
||||
`start` overrides the anchor and exists so the resolution can be exercised
|
||||
against a scratch tree. When these scripts are not inside a repository at
|
||||
all, cwd gets a turn; failing that the historical cwd-relative location
|
||||
stands, made absolute so an error message 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))
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -80,13 +142,20 @@ EXCLUSIVE_NS = ("type/", "severity/")
|
||||
REQUIRED_SECTIONS = ["## Summary", "## Spec"]
|
||||
AC_SECTION = "## Acceptance criteria"
|
||||
DEPENDS_SECTION = "## Depends on"
|
||||
ISSUES_SECTION = "## Issues"
|
||||
# Both sections name what an issue depends on, so both are edge sources and
|
||||
# both point the same way. In a `type/feature` that reads container -> child:
|
||||
# "the container is closed when its children are closed" IS a dependency.
|
||||
# "a child belongs to a feature" is membership, and membership has no place in
|
||||
# a dependency graph — which is why a child never names its container back.
|
||||
DEP_SECTIONS = (DEPENDS_SECTION, ISSUES_SECTION)
|
||||
# Per-type sections from the templates — absence is a warning, not a stop.
|
||||
EXPECTED_SECTIONS = {
|
||||
"bug": ["## Steps to reproduce", "## Expected", "## Actual", "## Environment"],
|
||||
"task": ["## Motivation"],
|
||||
"refactor": ["## Motivation", "## Invariants"],
|
||||
"test": ["## Motivation", "## Test cases"],
|
||||
"feature": ["## Motivation", "## Issues"],
|
||||
"feature": ["## Motivation", ISSUES_SECTION],
|
||||
"draft": ["## Notes"],
|
||||
}
|
||||
|
||||
@@ -179,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
|
||||
@@ -194,8 +263,11 @@ class Issue(object):
|
||||
|
||||
@property
|
||||
def is_local(self):
|
||||
"""True while this issue exists nowhere but here — a durable state,
|
||||
not a pending one."""
|
||||
"""True while this issue exists nowhere but here.
|
||||
|
||||
A complete state, not a pending one — and the state in which this file
|
||||
is the only copy of the work. An issue whose `origin` names somewhere
|
||||
else can be fetched from there again; this one cannot."""
|
||||
return self.origin == LOCAL
|
||||
|
||||
# -- taxonomy views ----------------------------------------------------
|
||||
@@ -267,24 +339,158 @@ def section_body(body, header):
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def body_dep_refs(body):
|
||||
"""Tokens referenced from `## Depends on` / `## Issues` only — never from
|
||||
prose, or a graph walk would drag in half the backlog. Returns whatever was
|
||||
written there (slugs, and `#N` on issues that came from a tracker)."""
|
||||
out, active = [], False
|
||||
def body_dep_ref_sections(body):
|
||||
"""[(section, ref)] for every reference under one of DEP_SECTIONS — never
|
||||
from prose, or a graph walk would drag in half the backlog. Refs are
|
||||
whatever was written there (slugs, and `#N` on issues that came from a
|
||||
tracker), deduplicated on first sight.
|
||||
|
||||
The section is carried out with the ref so a caller can name the one the
|
||||
reader actually has in front of them: a container's children come from
|
||||
`## Issues`, and pointing at `## Depends on` would name a section that is
|
||||
not in the file."""
|
||||
out, seen, section = [], set(), ""
|
||||
for line in (body or "").splitlines():
|
||||
if line.startswith("## "):
|
||||
active = line.strip() in (DEPENDS_SECTION, "## Issues")
|
||||
head = line.strip()
|
||||
section = head if head in DEP_SECTIONS else ""
|
||||
continue
|
||||
if not active:
|
||||
if not section:
|
||||
continue
|
||||
for tok in re.findall(r'#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b', line):
|
||||
ref = ("#" + tok[0]) if tok[0] else tok[1]
|
||||
if ref not in out:
|
||||
out.append(ref)
|
||||
if ref not in seen:
|
||||
seen.add(ref)
|
||||
out.append((section, ref))
|
||||
return out
|
||||
|
||||
|
||||
def body_dep_refs(body):
|
||||
"""Just the refs, in order of first appearance."""
|
||||
return [ref for _, ref in body_dep_ref_sections(body)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# checkboxes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# A checkbox is the one part of a body that is *state* and not prose, so the
|
||||
# format gives it markup of its own (references/format.md:163-164). It is item
|
||||
# markup, not a property of one section: `## Acceptance criteria` is the usual
|
||||
# home, but a type/feature keeps its children as checkboxes under `## Issues`
|
||||
# (format.md:275-277). The scan is therefore over the whole text and the
|
||||
# heading is only recorded, never required.
|
||||
CHECKBOX_RE = re.compile(
|
||||
r'^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+'
|
||||
r'\[(?P<box>[ xX])\](?=[ \t]|$)(?P<text>.*)$')
|
||||
# Any list item — a sibling ends the item above it, checkbox or not.
|
||||
LIST_ITEM_RE = re.compile(r'^[ \t]*([-*+]|\d+[.)])([ \t]|$)')
|
||||
FENCE_RE = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})')
|
||||
|
||||
Checkbox = collections.namedtuple(
|
||||
"Checkbox", "index line end_line checked text section")
|
||||
|
||||
|
||||
def checkboxes(text):
|
||||
"""Every checkbox item in `text`, in document order.
|
||||
|
||||
A pure function of the string it is given — no I/O, no store, no tracker.
|
||||
Pass an issue body (`Issue.body`) to get body-relative line numbers, or a
|
||||
whole file to get file-relative ones; nothing else changes.
|
||||
|
||||
Returns a list of `Checkbox` namedtuples:
|
||||
|
||||
index 1-based position in this list — what a user types to pick it
|
||||
line 1-based line of the `- [ ]` marker, in the text given
|
||||
end_line 1-based last line of the item, continuation lines included
|
||||
checked True for `[x]` / `[X]`, False for `[ ]`
|
||||
text the item's text; continuation lines joined with one space
|
||||
section nearest preceding `## ` heading, "" above the first one
|
||||
|
||||
Rules:
|
||||
|
||||
- Only a line matching CHECKBOX_RE opens an item. A wrapped ("continuation")
|
||||
line is part of the item above it, never an item of its own; the item
|
||||
runs to the next blank line, heading, code fence, or list marker.
|
||||
- Fenced code blocks are skipped whole: `- [ ]` inside a ``` fence is an
|
||||
example of the markup, not a box anybody may tick.
|
||||
- `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
|
||||
lists are seen too.
|
||||
"""
|
||||
lines = (text or "").splitlines()
|
||||
items, section, fence = [], "", ""
|
||||
for n, line in enumerate(lines, 1):
|
||||
m = FENCE_RE.match(line)
|
||||
if m:
|
||||
tok = m.group(1)
|
||||
if not fence:
|
||||
fence = tok
|
||||
elif tok[0] == fence[0] and len(tok) >= len(fence):
|
||||
fence = ""
|
||||
continue
|
||||
if fence:
|
||||
continue
|
||||
if line.startswith("## "):
|
||||
section = line.strip()
|
||||
continue
|
||||
if line.startswith("# "):
|
||||
section = ""
|
||||
continue
|
||||
m = CHECKBOX_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
end, parts = n, [m.group("text").strip()]
|
||||
for k in range(n, len(lines)): # lines[k] is line number k + 1
|
||||
nxt = lines[k]
|
||||
if (not nxt.strip() or nxt.startswith("#")
|
||||
or FENCE_RE.match(nxt) or LIST_ITEM_RE.match(nxt)):
|
||||
break
|
||||
end = k + 1
|
||||
parts.append(nxt.strip())
|
||||
items.append(Checkbox(len(items) + 1, n, end,
|
||||
m.group("box") != " ",
|
||||
" ".join(p for p in parts if p), section))
|
||||
return items
|
||||
|
||||
|
||||
def set_checkbox(text, item, checked=True):
|
||||
"""Return `text` with one checkbox set to `checked`.
|
||||
|
||||
Pure, and deliberately surgical: exactly one character of the input
|
||||
changes — the one between the brackets. Everything else, including
|
||||
trailing whitespace and the item's own wording, comes back byte for byte.
|
||||
That is the whole point of the function: ticking a box must not produce a
|
||||
diff wider than the state that changed.
|
||||
|
||||
`item` is a `Checkbox` from `checkboxes(text)` — the same text, or the
|
||||
line number will point at the wrong line — or a 1-based line number.
|
||||
Already in the requested state is a no-op: `text` is returned unchanged,
|
||||
and an existing `[X]` keeps its capital.
|
||||
"""
|
||||
line_no = item.line if isinstance(item, Checkbox) else int(item)
|
||||
off = 0
|
||||
for n, raw in enumerate(text.splitlines(True), 1):
|
||||
if n == line_no:
|
||||
m = CHECKBOX_RE.match(raw.rstrip("\r\n"))
|
||||
if not m:
|
||||
raise ValueError("line %d is not a checkbox item" % line_no)
|
||||
if (m.group("box") != " ") == bool(checked):
|
||||
return text
|
||||
box = off + m.start("box")
|
||||
return text[:box] + ("x" if checked else " ") + text[box + 1:]
|
||||
off += len(raw)
|
||||
raise ValueError("line %d is past the end of the text" % line_no)
|
||||
|
||||
|
||||
def checkbox_progress(text):
|
||||
"""(done, total) over every checkbox in `text`; (0, 0) when it has none.
|
||||
|
||||
Computed on the fly, on purpose. Progress is not a metadata field: it is
|
||||
the body read back, and the body is the only place the state lives."""
|
||||
items = checkboxes(text)
|
||||
return sum(1 for c in items if c.checked), len(items)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# validation
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -342,12 +548,17 @@ def validate(issue, known_ids=None):
|
||||
warn.append("depends on %r, which is not in the store" % d)
|
||||
|
||||
# `depends:` is the machine-readable graph; the body section is prose for
|
||||
# humans. They drift silently unless something says so.
|
||||
# humans. They drift silently unless something says so. Name the section
|
||||
# the reference actually came from — for a container that is `## Issues`.
|
||||
listed = set(issue.depends)
|
||||
for ref in body_dep_refs(issue.body):
|
||||
for section, ref in body_dep_ref_sections(issue.body):
|
||||
if not ref.startswith("#") and ref not in listed:
|
||||
warn.append("%s mentions %r but `depends:` does not list it"
|
||||
% (DEPENDS_SECTION, ref))
|
||||
% (section, ref))
|
||||
|
||||
# An unticked checkbox is never a finding — neither an error nor a
|
||||
# warning. `- [ ]` is work not done yet, which is the normal state of a
|
||||
# perfectly well-formed issue. Reading that state is issue_ac.py's job.
|
||||
|
||||
return err, warn
|
||||
|
||||
@@ -356,15 +567,102 @@ def validate(issue, known_ids=None):
|
||||
# store
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class StoreMissing(Exception):
|
||||
"""The store directory is not there.
|
||||
|
||||
Deliberately a different answer from "the store is empty". One is a path
|
||||
that does not exist, the other is a repository with no issues filed yet, and
|
||||
conflating the two is exactly what made a missed directory look like an
|
||||
empty backlog."""
|
||||
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
Exception.__init__(self, "store %s does not exist" % root)
|
||||
|
||||
|
||||
def store_exists(root):
|
||||
return os.path.isdir(root)
|
||||
|
||||
|
||||
def require_store(root):
|
||||
"""Assert the store is there before reading or writing it."""
|
||||
if not os.path.isdir(root):
|
||||
raise StoreMissing(root)
|
||||
return root
|
||||
|
||||
|
||||
def create_store(root):
|
||||
"""Create the store; True when it actually made the directory.
|
||||
|
||||
Only the commands that legitimately bootstrap a store call this — issue_new
|
||||
and pull — and both announce it. Nothing creates a store as a side effect of
|
||||
a write any more: a missing directory is something to report, not something
|
||||
to conjure."""
|
||||
if os.path.isdir(root):
|
||||
return False
|
||||
os.makedirs(root)
|
||||
return True
|
||||
|
||||
|
||||
def store_error(root):
|
||||
"""Why `root` cannot be read as a store, or None when it holds issues.
|
||||
|
||||
The two messages are distinct on purpose — see StoreMissing."""
|
||||
if not os.path.isdir(root):
|
||||
return ("store %s does not exist — nothing was created; pass --out to "
|
||||
"point elsewhere" % root)
|
||||
if not all_ids(root):
|
||||
return "store %s exists but is empty" % root
|
||||
return None
|
||||
|
||||
|
||||
def path_of(root, id):
|
||||
return os.path.join(root, "%s.md" % id)
|
||||
|
||||
|
||||
def all_ids(root):
|
||||
"""Every issue in the store, by slug.
|
||||
|
||||
An issue file is named by its slug and a slug has no dot in it (SLUG_OK),
|
||||
so `<id>.comments.md` — the thread the sync layer parks beside an issue —
|
||||
is not one, and neither is anything else that grew a second extension.
|
||||
Without that rule `wire-sqlc.comments` reads as an issue called
|
||||
`wire-sqlc.comments`, and a bare `push.py` tries to file the comment thread
|
||||
as a unit of work."""
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
return sorted(f[:-3] for f in os.listdir(root)
|
||||
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-")))
|
||||
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
|
||||
and "." not in f[:-3])
|
||||
|
||||
|
||||
def slug_files(root, id):
|
||||
"""Every file the store holds under one slug — the issue and its sidecars.
|
||||
|
||||
`<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
||||
companion another layer parked there (`<id>.comments.md` is the one that
|
||||
exists today). `all_ids` already refuses to read those as issues because a
|
||||
slug has no dot in it; this is the same rule read the other way round.
|
||||
|
||||
Which is how the domain can remove an issue *completely* without learning
|
||||
what any of those companions are: it does not need to know that a comment
|
||||
thread exists to know that a file named after this issue belongs to it and
|
||||
goes when it goes. The issue's own file comes first — it is the headline of
|
||||
any receipt printed from this list.
|
||||
|
||||
A missing store is an empty list, not an error: nothing is there to remove.
|
||||
"""
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
own, sidecars = [], []
|
||||
for name in sorted(os.listdir(root)):
|
||||
if not name.startswith("%s." % id):
|
||||
continue
|
||||
p = os.path.join(root, name)
|
||||
if not os.path.isfile(p):
|
||||
continue
|
||||
(own if name == "%s.md" % id else sidecars).append(p)
|
||||
return own + sidecars
|
||||
|
||||
|
||||
def load(root, id):
|
||||
@@ -377,7 +675,7 @@ def load_all(root):
|
||||
|
||||
|
||||
def save(root, issue):
|
||||
os.makedirs(root, exist_ok=True)
|
||||
require_store(root)
|
||||
p = path_of(root, issue.id)
|
||||
with open(p, "w") as f:
|
||||
f.write(issue.to_text())
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_ac.py — list and tick the checkboxes in an issue's body. Offline.
|
||||
|
||||
issue_ac.py wire-sqlc-appclick numbered list with state
|
||||
issue_ac.py wire-sqlc-appclick --check 3 by number
|
||||
issue_ac.py wire-sqlc-appclick --check регресс by substring
|
||||
issue_ac.py wire-sqlc-appclick --uncheck 3
|
||||
|
||||
A checkbox is the one part of a body that is *state* and not prose. Everything
|
||||
else is written once; boxes get ticked as the work goes, and until now the only
|
||||
ways to tick one were a human with an editor or a model rewriting the whole
|
||||
body — the second worse than the first, because the rewrite re-flows the text
|
||||
and the issue's diff swells around a change of one character. This changes that
|
||||
one character and nothing else.
|
||||
|
||||
Named after `## Acceptance criteria`, where most boxes live, but every checkbox
|
||||
in the body is listed and tickable: a type/feature keeps its children under
|
||||
`## Issues`, and binding this to one heading would silently lose half of them.
|
||||
|
||||
A substring picks an item only when it picks exactly one. Two matches is an
|
||||
error listing both — a coin flip would tick the wrong box and look like it
|
||||
worked.
|
||||
|
||||
Delivering the changed body to a tracker is not part of this: that is
|
||||
`push.py --update` in /tea:sync.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
|
||||
NUMBER = re.compile(r'^\d+$')
|
||||
|
||||
|
||||
def box(c):
|
||||
return "[x]" if c.checked else "[ ]"
|
||||
|
||||
|
||||
def listing(items):
|
||||
"""The numbered list, grouped by the heading each item sits under."""
|
||||
out, section = [], None
|
||||
for c in items:
|
||||
if c.section != section:
|
||||
section = c.section
|
||||
out.append("")
|
||||
out.append(section or "(above the first heading)")
|
||||
out.append(" %2d %s %s" % (c.index, box(c), c.text))
|
||||
return out
|
||||
|
||||
|
||||
def select(items, needle):
|
||||
"""Resolve a --check/--uncheck argument to exactly one item, or exit."""
|
||||
needle = (needle or "").strip()
|
||||
if not needle:
|
||||
sys.exit("issue_ac.py: empty selector — give an item number or a substring")
|
||||
if NUMBER.match(needle):
|
||||
n = int(needle)
|
||||
if not 1 <= n <= len(items):
|
||||
sys.exit("issue_ac.py: no item %d — the issue has %d" % (n, len(items)))
|
||||
return items[n - 1]
|
||||
hits = [c for c in items if needle.lower() in c.text.lower()]
|
||||
if not hits:
|
||||
sys.exit("issue_ac.py: nothing matches %r" % needle)
|
||||
if len(hits) > 1:
|
||||
sys.exit("\n".join(
|
||||
["issue_ac.py: %r matches %d items — narrow it down, or use a number:"
|
||||
% (needle, len(hits))]
|
||||
+ [" %2d %s %s" % (c.index, box(c), c.text) for c in hits]))
|
||||
return hits[0]
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="List and tick an issue's checkboxes (offline)")
|
||||
ap.add_argument("id", help="issue id (the slug, without .md)")
|
||||
g = ap.add_mutually_exclusive_group()
|
||||
g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring")
|
||||
g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
path = issue.path_of(args.out, args.id)
|
||||
if not os.path.exists(path):
|
||||
sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out))
|
||||
# newline="": no translation in either direction. Byte-for-byte means the
|
||||
# line endings too — reading a CRLF file in text mode and writing it back
|
||||
# would rewrite every line while claiming to have changed one character.
|
||||
with open(path, newline="") as f:
|
||||
text = f.read()
|
||||
|
||||
# The whole file, not just the body: line numbers then point at the file,
|
||||
# and the metadata block is rewritten by nobody. Round-tripping through
|
||||
# Issue.to_text() would re-render metadata and re-strip the body, which is
|
||||
# exactly the byte-level churn this script exists to avoid.
|
||||
items = issue.checkboxes(text)
|
||||
needle = args.check if args.check is not None else args.uncheck
|
||||
|
||||
if not items:
|
||||
if needle is not None:
|
||||
sys.exit("issue_ac.py: %s has no checkboxes" % args.id)
|
||||
print("%s — no checkboxes" % args.id)
|
||||
return 0
|
||||
|
||||
if needle is None:
|
||||
done = sum(1 for c in items if c.checked)
|
||||
print("%s — %d/%d %s" % (args.id, done, len(items), path))
|
||||
print("\n".join(listing(items)))
|
||||
return 0
|
||||
|
||||
checked = args.check is not None
|
||||
item = select(items, needle)
|
||||
new = issue.set_checkbox(text, item, checked)
|
||||
verb = "checked" if checked else "unchecked"
|
||||
if new == text:
|
||||
print("unchanged %2d %s %s" % (item.index, box(item), item.text))
|
||||
return 0
|
||||
|
||||
with open(path, "w", newline="") as f:
|
||||
f.write(new)
|
||||
issue_index.build(args.out)
|
||||
|
||||
done, total = issue.checkbox_progress(new)
|
||||
print("%s %2d %s %s" % (verb, item.index, "[x]" if checked else "[ ]", item.text))
|
||||
print("%s — %d/%d %s:%d" % (args.id, done, total, path, item.line))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+6
-3
@@ -26,16 +26,19 @@ def main():
|
||||
ap.add_argument("ids", nargs="*", help="ids to check (default: all)")
|
||||
ap.add_argument("--quiet", action="store_true", help="exit code only")
|
||||
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
problem = issue.store_error(args.out)
|
||||
if problem:
|
||||
sys.exit("issue_check.py: %s" % problem)
|
||||
|
||||
issues = issue.load_all(args.out)
|
||||
ids = args.ids or sorted(issues)
|
||||
for i in ids:
|
||||
if i not in issues:
|
||||
sys.exit("issue_check.py: no issue %r in %s" % (i, args.out))
|
||||
if not ids:
|
||||
sys.exit("issue_check.py: store %s is empty" % args.out)
|
||||
|
||||
known = set(issues)
|
||||
bad = 0
|
||||
@@ -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())
|
||||
+36
-9
@@ -7,8 +7,12 @@ the index acknowledges that a tracker exists: `local` means the issue has never
|
||||
left this machine, `gitea` means the sync layer has pushed or pulled it. Both
|
||||
are ordinary issues here.
|
||||
|
||||
The store is <repo root>/tmp/issues unless --out says otherwise; an existing
|
||||
store with nothing in it gets an "_empty_" table, a store that is not there is
|
||||
an error rather than a directory to create.
|
||||
|
||||
Usage:
|
||||
issue_index.py [--out tmp/issues]
|
||||
issue_index.py [--out DIR]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
@@ -26,7 +30,20 @@ def cell(v):
|
||||
return v.replace("|", "\\|") or "—"
|
||||
|
||||
|
||||
def progress(body):
|
||||
"""`3/7` for a body with checkboxes, "" for one without.
|
||||
|
||||
Counted from the body every time the index is built and stored nowhere —
|
||||
the boxes are the state, and a second copy of it in a metadata field would
|
||||
be wrong by the next edit."""
|
||||
done, total = issue.checkbox_progress(body)
|
||||
return "%d/%d" % (done, total) if total else ""
|
||||
|
||||
|
||||
def build(root):
|
||||
# An index of a store that is not there is not an empty index, it is a bad
|
||||
# path. Raising beats writing INDEX.md into a directory nobody asked for.
|
||||
issue.require_store(root)
|
||||
issues = issue.load_all(root)
|
||||
rows = []
|
||||
for i in sorted(issues):
|
||||
@@ -35,6 +52,7 @@ def build(root):
|
||||
rows.append({
|
||||
"id": i,
|
||||
"state": cell(iss.state),
|
||||
"progress": progress(iss.body),
|
||||
"type": cell(iss.type),
|
||||
"labels": cell(rest),
|
||||
"title": cell(iss.title),
|
||||
@@ -50,13 +68,16 @@ def build(root):
|
||||
"Every issue this project knows about. `origin: local` means it "
|
||||
"exists nowhere else — a complete state, not a pending one. Any "
|
||||
"other value names the tracker it also lives in; the handle is in "
|
||||
"the file. Rebuild with `issue_index.py`.", ""]
|
||||
"the file. `progress` counts the body's checkboxes, ticked over "
|
||||
"total, and is blank for an issue that has none — read off the "
|
||||
"body at build time, stored nowhere. Rebuild with `issue_index.py`; "
|
||||
"tick a box with `issue_ac.py`.", ""]
|
||||
if rows:
|
||||
out += ["| id | state | type | labels | title | milestone | depends | origin |",
|
||||
"|---|---|---|---|---|---|---|---|"]
|
||||
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s |" % (
|
||||
r["id"], r["id"], r["state"], r["type"], r["labels"], r["title"],
|
||||
r["milestone"], r["depends"], r["origin"]) for r in rows]
|
||||
out += ["| id | state | progress | type | labels | title | milestone | depends | origin |",
|
||||
"|---|---|---|---|---|---|---|---|---|"]
|
||||
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % (
|
||||
r["id"], r["id"], r["state"], r["progress"], r["type"], r["labels"],
|
||||
r["title"], r["milestone"], r["depends"], r["origin"]) for r in rows]
|
||||
else:
|
||||
out.append("_empty_")
|
||||
|
||||
@@ -71,7 +92,6 @@ def build(root):
|
||||
|
||||
out.append("")
|
||||
path = os.path.join(root, "INDEX.md")
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write("\n".join(out))
|
||||
return path, len(rows)
|
||||
@@ -79,9 +99,16 @@ def build(root):
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Rebuild the local issue index (offline)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
# An existing store with nothing in it is a legitimate thing to index — it
|
||||
# gets an "_empty_" table. A store that is not there is not.
|
||||
try:
|
||||
path, n = build(args.out)
|
||||
except issue.StoreMissing as e:
|
||||
sys.exit("issue_index.py: %s — nothing was created; create an issue with "
|
||||
"issue_new.py, or pass --out" % e)
|
||||
print("%s — %d issue(s)" % (path, n))
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
issue_new.py — create an issue in the local store. Offline, always.
|
||||
|
||||
The issue is real the moment this writes the file. Nothing is pending, nothing
|
||||
is a draft awaiting a tracker: `origin: local` is a durable state, and pushing
|
||||
it to Gitea later (see /tea:sync) is optional and additive.
|
||||
is a draft awaiting a tracker: `origin: local` is a complete state and pushing
|
||||
it to Gitea later (see /tea:sync) is optional.
|
||||
|
||||
While it says `local`, this file is the ONLY copy of the work — the store, not
|
||||
a cache of anything. That is what a push changes: it hands the issue to the
|
||||
tracker and removes the file.
|
||||
|
||||
issue_new.py --type task --title "Wire sqlc into the appclick repo layer" \
|
||||
--label tech/sql --label comp/appclick
|
||||
@@ -151,7 +155,8 @@ def main():
|
||||
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
|
||||
ap.add_argument("--depends", action="append", default=[],
|
||||
help="id this issue depends on; repeat")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
labels = ["type/%s" % args.type]
|
||||
@@ -177,6 +182,11 @@ def main():
|
||||
labels=labels, assignees=args.assignee, milestone=args.milestone,
|
||||
depends=args.depends)
|
||||
|
||||
# The first issue in a fresh checkout has to create the store, but it says
|
||||
# so — and it says where, because the path is absolute.
|
||||
if issue.create_store(args.out):
|
||||
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
|
||||
|
||||
path = issue.save(args.out, iss)
|
||||
issue_index.build(args.out)
|
||||
print("%s [type/%s] %s" % (path, args.type, args.title))
|
||||
@@ -66,12 +66,15 @@ def main():
|
||||
ap.add_argument("--depth", type=int, default=6, help="max depth (default: 6)")
|
||||
ap.add_argument("--write", action="store_true",
|
||||
help="also write tmp/issues/tree-<slug>.md")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
problem = issue.store_error(args.out)
|
||||
if problem:
|
||||
sys.exit("issue_tree.py: %s" % problem)
|
||||
|
||||
issues = issue.load_all(args.out)
|
||||
if not issues:
|
||||
sys.exit("issue_tree.py: store %s is empty" % args.out)
|
||||
edges = issue.graph(issues)
|
||||
|
||||
roots = args.ids
|
||||
@@ -0,0 +1,537 @@
|
||||
---
|
||||
name: sync
|
||||
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
|
||||
---
|
||||
|
||||
# /tea:sync — the bridge between the local store and Gitea
|
||||
|
||||
One job: translate between `tmp/issues/<id>.md` and Gitea's JSON, and carry the
|
||||
result over the wire. Everything about **what an issue is** — format, types,
|
||||
validation, the dependency graph — belongs to `/tea:issue` and is imported from
|
||||
there, never redefined here.
|
||||
|
||||
Direction of knowledge, and it is one-way:
|
||||
|
||||
```
|
||||
skills/issue domain what an issue is offline, no tracker
|
||||
▲
|
||||
│ imports
|
||||
skills/sync bridge map.py md <-> Gitea JSON, pure, no I/O
|
||||
_gitea.py login, tea api, pagination, filters
|
||||
```
|
||||
|
||||
`skills/issue` never imports anything from here.
|
||||
|
||||
## Never read an issue through raw `tea`
|
||||
|
||||
`tea issues <n> -o json` and `tea api .../issues/<n>` dump the full payload —
|
||||
avatars, nested user objects, every comment body — into your context whether
|
||||
you need it or not. Use `pull.py`: it writes flat markdown and prints a compact
|
||||
index.
|
||||
|
||||
## Scripts
|
||||
|
||||
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
|
||||
operator's pin from `.claude/settings.local.json` through
|
||||
`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
|
||||
not merely the same file, so a directory where `tea` works is a directory where
|
||||
these work. That includes a **git worktree**, whose untracked pin sits in the
|
||||
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
|
||||
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
|
||||
`/tea:auth`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
|
||||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
|
||||
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
|
||||
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
|
||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
|
||||
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
|
||||
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
|
||||
|
||||
Key forms for `<key>`: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo
|
||||
defaults to the current directory's git remote; add `--repo owner/repo` outside
|
||||
one.
|
||||
|
||||
`--out` defaults to `issue.ISSUE_ROOT` on every one of them — the domain layer's
|
||||
`<repo root>/tmp/issues`, resolved from the scripts' own location rather than
|
||||
cwd. Both layers therefore address the same store by construction, from any
|
||||
directory. Pass `--out` to override; a relative one stays relative to cwd. Only
|
||||
`pull.py` will create a missing store, and it says so on stderr.
|
||||
|
||||
## Identity mapping
|
||||
|
||||
The local id is a slug; Gitea's is a number. While a working copy exists, the
|
||||
pair is in the file:
|
||||
|
||||
```
|
||||
origin: gitea
|
||||
gitea: claude-skills/tea#42
|
||||
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
```
|
||||
|
||||
But the file is deleted on push, so the pair also lives in two places that
|
||||
outlast it: `tmp/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
|
||||
marker in the issue body on the Gitea side. See [How the slug comes
|
||||
back](#how-the-slug-comes-back).
|
||||
|
||||
`.remote.json` used to be described as an index over the files. It is not one
|
||||
any more — the files are a subset of what it knows, and its entries deliberately
|
||||
outlive them. It is the local **ledger**, and `_gitea.rebuild_map` merges into it
|
||||
rather than reconstructing it, so a rebuild can never drop a pushed issue.
|
||||
Nothing prunes it: "no file" no longer means "no such issue". Delete it anyway
|
||||
and nothing is lost — the next pull reads the slug off the marker and writes the
|
||||
entry back.
|
||||
|
||||
A retitled issue keeps its slug: neither record is keyed by the title.
|
||||
|
||||
## Pulling
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/pull.py 42
|
||||
python3 <skill-base-dir>/scripts/pull.py --milestone 6 # id or title
|
||||
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
|
||||
python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20
|
||||
python3 <skill-base-dir>/scripts/pull.py 40 --no-deps # this issue only
|
||||
```
|
||||
|
||||
Do not loop over numbers to pull a group — pass the filter. The list endpoint
|
||||
carries the issue bodies, so a milestone costs **one request per 50 issues**,
|
||||
not one per issue. Filters AND together; `--state` defaults to `open`;
|
||||
`--limit` to 100. Keys and filters are mutually exclusive.
|
||||
|
||||
**A pull is how a pushed issue comes back.** Push deleted the file, so this is
|
||||
not refreshing a copy you kept — it is how the copy comes to exist. It lands
|
||||
under the same slug it had before, even after a rename in Gitea and even on a
|
||||
machine that has never seen the issue; see [How the slug comes
|
||||
back](#how-the-slug-comes-back).
|
||||
|
||||
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
||||
local edits are lost, with one exception: [checkbox
|
||||
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
|
||||
disk.
|
||||
|
||||
**Closed issues stay out of the store.** In filter mode they are enumerated
|
||||
but not written: `--state all` still shows the whole picture, only `--state
|
||||
closed` puts one on disk, and the number left out goes to stderr. An issue
|
||||
already on disk is refreshed either way — the local copy learns it was closed
|
||||
instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a
|
||||
closed issue as always, because an address is not a bulk read.
|
||||
|
||||
**`--limit N` bounds the write, not the selection.** N is how many issues this
|
||||
run leaves in the store — written, or left in place by `--cached`. Closed ones
|
||||
that were enumerated and thrown away do not spend it, so `--limit 20` over a
|
||||
milestone whose first 30 issues are closed still writes 20, as long as 20 open
|
||||
ones are there to write. Pagination follows the budget rather than the other way
|
||||
round:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| budget full | the next page is never requested |
|
||||
| pages run out | fewer than N, and that is the honest answer |
|
||||
| filter matches almost only closed issues | at most 4× the pages N would need if nothing were dropped, then a warning on stderr and a short answer — raising `--limit` raises that ceiling too |
|
||||
| dependencies | outside the count: a blocker is followed because a stored issue named it, not because the filter selected it — so `--limit 20` can leave more than 20 files behind |
|
||||
|
||||
`remote.py --limit` means something else, deliberately: it caps the **listing**,
|
||||
closed issues included. It writes nothing, so there is no write for a limit to
|
||||
bound — enumeration is its whole job.
|
||||
|
||||
**Comments come with every pull** — there is no flag. An issue that has a
|
||||
thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter
|
||||
mode alike, and the issue's output line says how many. An issue with none
|
||||
costs nothing: the count arrives in the list payload, so no request is made
|
||||
and no file is written — and a file left over from a thread that has since
|
||||
been emptied is deleted. `--cached` skips the thread along with the body, so a
|
||||
skipped issue makes one request for its links and no other.
|
||||
|
||||
**Dependencies come with every pull too, and this one costs.** A pull answers
|
||||
with the unit of work — the issue and what blocks it — so `depends:` is filled
|
||||
from Gitea's native graph and every blocker is pulled as well, recursively, down
|
||||
to `--depth` (default 3). It has to come from the native graph: the body's
|
||||
`## Depends on` section holds slugs, never `#N`, so there is no edge to recover
|
||||
from the text. `--no-deps` turns off both halves. `--deps` is still accepted and
|
||||
does nothing — it names the default.
|
||||
|
||||
| | requests |
|
||||
|---|---|
|
||||
| every issue that lands in the store | **+1** — `GET …/issues/{n}/dependencies`, fetched once and used twice (fills `depends:`, steers the walk) |
|
||||
| every blocker the selection did not already carry | **+1** to fetch it, then its own links, until `--depth` |
|
||||
| a closed issue filter mode drops | 0 — nothing was stored, so there is no unit of work to complete |
|
||||
| `--milestone X` over 50 open issues | 1 list request + 50, plus a pair per outside blocker — it used to be 1 |
|
||||
| the same with `--no-deps` | 1 |
|
||||
|
||||
**A blocker the filter did not select still lands in the store, deliberately.**
|
||||
`--milestone X` can leave an issue from milestone Y on disk; `--label` can leave
|
||||
an unlabelled one. It is there because a stored issue names it, not because it
|
||||
matched. The exception is a closed blocker: closed is not a unit of work, filter
|
||||
mode drops it like any other closed issue, and the `depends:` edge to it goes
|
||||
with it — nothing is left pointing at a file that is not there. Key mode
|
||||
(`pull.py 42`) has no such rule and stores it.
|
||||
|
||||
Two traps this handles for you:
|
||||
|
||||
- **Gitea silently ignores an unresolvable milestone filter** and returns the
|
||||
whole backlog. `pull.py` resolves the milestone first (exiting with the real
|
||||
ones if it does not exist) and re-checks every returned issue locally. Never
|
||||
trust a raw `tea api ...issues?milestones=X` for this.
|
||||
- **Projects are not fetchable.** The projects API is not exposed (404 on
|
||||
Gitea 1.26 for `repos/…/projects`, `orgs/…/projects`, `projects/{id}`). Use
|
||||
milestones or labels; project columns live in the web UI only.
|
||||
|
||||
After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no
|
||||
extra requests.
|
||||
|
||||
### Checkboxes are the one exception
|
||||
|
||||
A checkbox is state, not prose, and it is the one thing a pull does **not**
|
||||
overwrite. For a checkbox line whose **text** matches a line in the local copy,
|
||||
`[x]` wins from whichever side has it — tick it in the web UI, tick it locally,
|
||||
tick it in both, the tick survives.
|
||||
|
||||
| part of the body | what a pull does to it |
|
||||
|---|---|
|
||||
| prose, headings, everything not a checkbox | overwritten from the server, whole, as before |
|
||||
| a checkbox whose text is in the local copy | `[x]` from **either** side wins |
|
||||
| a checkbox whose text is not in the local copy | taken from the server as it stands, ticked or not |
|
||||
| any issue the store has never seen | written exactly as the server sent it |
|
||||
|
||||
This is not drift tracking — [Drift](#drift) stands. A tick is **monotone**: an
|
||||
item only travels `[ ]` → `[x]`, so joining the two sides is a set union, not a
|
||||
conflict to resolve. No base version is kept and nothing is compared against
|
||||
one; one rule for one line type replaces the whole mechanism.
|
||||
|
||||
**The price, and it is real: a box unticked in the web UI comes back on the next
|
||||
pull.** Unticking is not monotone, so the union cannot see it. Untick locally,
|
||||
then `push.py --update` — the body goes up whole and the server follows.
|
||||
|
||||
Matching is on the item's text after the domain parser has stripped it and
|
||||
rejoined wrapped lines with single spaces, so rewrapping a long item keeps its
|
||||
tick. Rewording one does not: different text is a different item. The same text
|
||||
twice in a body is read as a set — one ticked local copy ticks every server line
|
||||
with that text.
|
||||
|
||||
The parsing is `/tea:issue`'s (`issue.checkboxes` / `issue.set_checkbox`),
|
||||
imported, never reimplemented here. The rule itself is
|
||||
`map.merge_checkbox_state`: pure, and testable without a Gitea anywhere.
|
||||
|
||||
## Pushing
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/push.py --dry-run # validate, no network
|
||||
python3 <skill-base-dir>/scripts/push.py # every local-only issue
|
||||
python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
|
||||
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
|
||||
```
|
||||
|
||||
**A successful push DELETES the local file** — `tmp/issues/<id>.md` and
|
||||
`<id>.comments.md` — and prints the number and URL the issue now lives at:
|
||||
|
||||
```
|
||||
created wire-sqlc-appclick #42 https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
dropped /repo/tmp/issues/wire-sqlc-appclick.md
|
||||
pull.py 42 to work on it again
|
||||
```
|
||||
|
||||
Once the tracker has the issue, the tracker *is* the issue. What is left in the
|
||||
store is what has not left this machine. There is no second copy, so there is
|
||||
nothing to reconcile and no "is mine the fresh one?" to answer — see
|
||||
[Drift](#drift).
|
||||
|
||||
**`--update` deletes too. One rule, no exception.** A PATCH is a push; an issue
|
||||
that has just been sent is no more local than one that was just created. Edit an
|
||||
issue by pulling it, changing it, pushing it — the copy is gone again after.
|
||||
|
||||
### What has to be true before anything is deleted
|
||||
|
||||
In order, and the delete is last:
|
||||
|
||||
1. the transport returned — `tea` ran and exited 0 (a non-2xx exits the run), and
|
||||
2. the answer is an object carrying a positive integer `number`, and on
|
||||
`--update` **the same number that was PATCHed** (`push.confirmed_number`), and
|
||||
3. `.remote.json` has been written with number → slug.
|
||||
|
||||
Network down, a 422, an empty body, an answer for a different issue: the file is
|
||||
still there and the run stops with the path in the error. An `origin: local`
|
||||
issue that was not sent — including a local-only dependency that push only read
|
||||
to warn about — is never touched. `--dry-run` deletes nothing and sends nothing.
|
||||
|
||||
### How the slug comes back
|
||||
|
||||
The slug is the issue's identity and the format promises it is stable for life,
|
||||
so it cannot live only in a file that push is about to delete. Two records, and
|
||||
the durable one is not local:
|
||||
|
||||
| where | survives | how |
|
||||
|---|---|---|
|
||||
| `<!-- tea:id wire-sqlc-appclick -->` | a rename in the web UI, a lost `.remote.json`, a fresh clone, another machine | first line of the **tracker-side** body; an HTML comment, so Gitea renders nothing |
|
||||
| `tmp/issues/.remote.json` | the file being deleted | number → slug, written before the delete |
|
||||
|
||||
`pull.py` consults the ledger first (it is the one that knows about files on
|
||||
disk right now), then the marker, then falls back to slugifying the title for an
|
||||
issue filed in the web UI that has never had a local name. A marker is only
|
||||
taken at its word when that slug is free — it never overwrites an issue already
|
||||
in the store.
|
||||
|
||||
**The marker never appears in the local file.** `map.to_payload` puts exactly
|
||||
one at the top on the way up, `map.from_api` strips every one on the way down.
|
||||
Strip-all-then-prepend-one is the whole mechanism, which is why a body cannot
|
||||
accumulate them however many round trips it makes, and why a body that somehow
|
||||
gained two is cleaned on the next pull.
|
||||
|
||||
`depends:` survives the same round trip through Gitea's native links (below):
|
||||
push writes them, every `pull.py` reads them back, and the ledger turns the
|
||||
numbers into the slugs they had here.
|
||||
|
||||
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
|
||||
at most one `severity/*`, English title with no type prefix, `## Summary` /
|
||||
`## Spec` / `## Acceptance criteria` present). `--force` posts anyway — say why
|
||||
when you use it.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Issues go up in topological order, dependencies first, and **the graph goes up
|
||||
with them**. Once an issue has its number, every `depends:` entry that also has
|
||||
one becomes a native Gitea link, so the tracker shows the blocking panel and
|
||||
refuses to close a blocked issue before its blocker.
|
||||
|
||||
The two directions are symmetric, and they use the same endpoint:
|
||||
|
||||
| | direction | endpoint |
|
||||
|---|---|---|
|
||||
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
|
||||
| `pull.py` (default; `--no-deps` off) | native links → `depends:` | `GET …/issues/{n}/dependencies` |
|
||||
|
||||
The POST body is Gitea's `IssueMeta` — `{"index", "owner", "repo"}` naming the
|
||||
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
|
||||
url depend on the issue in the form"). `owner`/`repo` travel with it, so a
|
||||
dependency in another repo links correctly.
|
||||
|
||||
- Topological order means the blocker already has its number — no second pass.
|
||||
- A link the tracker already has is skipped: push GETs the existing ones first,
|
||||
so a repeat push is a no-op and a 409 never happens. Should a link fail
|
||||
anyway, it is a warning, not a dead run — the issues are already created.
|
||||
- `--update` carries links that appeared in `depends:` after the first push.
|
||||
- `--dry-run` prints every link it would make (`#?` for a number this run has
|
||||
not handed out yet) and makes no request at all.
|
||||
- **Removing a link is out of scope.** Push only adds. A dependency deleted
|
||||
from `depends:` leaves its Gitea link standing; drop it in the web UI or with
|
||||
`tea api -X DELETE …/issues/N/dependencies`.
|
||||
|
||||
A dependency that is still local-only is reported, not silently dropped: it has
|
||||
no number, so it gets no link. The body's `## Depends on` prose is sent verbatim
|
||||
either way — nothing is lost, but the tracker shows no edge until that issue is
|
||||
pushed too.
|
||||
|
||||
Missing labels are created with the canonical color and, for `type/*` and
|
||||
`severity/*`, `exclusive: true` — `tea labels create` cannot set that field
|
||||
(tea 0.14.2), so it goes through `tea api`. Colors live in `map.py`; the names
|
||||
and their meaning come from the domain taxonomy.
|
||||
|
||||
That is per-push and piecemeal: a repo only ever grows the labels its issues
|
||||
happened to use, so filtering by `type/bug` in the web UI stays impossible
|
||||
until someone pushes a bug. `labels.py` lays down the whole set — the 11
|
||||
`type/*` and `severity/*` names — in one run:
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/labels.py --dry-run # the plan, no writes
|
||||
python3 <skill-base-dir>/scripts/labels.py # create what is missing
|
||||
```
|
||||
|
||||
It reads the repo's labels first. An exactly-matching name is never re-created
|
||||
and never patched. A **lookalike** — `bug`, `Bug`, `type: bug`, `kind/bug` —
|
||||
is reported with its id and left alone: renaming somebody else's label is a
|
||||
decision, not a migration. A color or `exclusive` that drifted is printed, and
|
||||
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
|
||||
`comp/*` are open-ended by design and stay push-created.
|
||||
|
||||
Labels belong to the repository, not to any issue, so this one runs on a
|
||||
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
|
||||
and nothing creates it. The request bodies go to `tmp/payload/` (below).
|
||||
|
||||
A milestone must already exist in the repo — push attaches, it does not create.
|
||||
|
||||
`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 sends it up as `ref`; a value already there is never
|
||||
overwritten, neither on create nor on `--update`. Nothing is written back to
|
||||
the issue file — there is no file left to write to, because a successful push
|
||||
deletes it. The branch comes back on disk with the next `pull.py <n>`, from
|
||||
the tracker. On a detached HEAD or outside
|
||||
a git repo no `ref` is sent and a warning names the issues that went up without
|
||||
one. Reading the branch is the only thing these scripts ask git for — they
|
||||
never check out, create, or write anything.
|
||||
|
||||
The branch comes from the **current directory**, so run `push.py` from the tree
|
||||
the work is on. In a git worktree that is the worktree, and it is now also
|
||||
where the pin resolves from: the old workaround for the pin — run the scripts
|
||||
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
|
||||
is the one thing `branch:` exists to record.
|
||||
|
||||
## Closing and reopening
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/close.py wire-sqlc-appclick # by slug
|
||||
python3 <skill-base-dir>/scripts/close.py 42 '#43' # by number
|
||||
python3 <skill-base-dir>/scripts/close.py --reopen 42
|
||||
python3 <skill-base-dir>/scripts/close.py --dry-run 42 43 # no request at all
|
||||
```
|
||||
|
||||
`close.py` is the only supported way to move `state:`. Never hand-roll
|
||||
`tea api -X PATCH -d '{"state":"closed"}' repos/OWNER/REPO/issues/N`: it spells
|
||||
out the owner, the repo and the request body — the three things this layer
|
||||
exists to hide — and it needs a `Bash(tea api *)` permission that also covers
|
||||
`-X DELETE` on the repository.
|
||||
|
||||
**State only.** The payload is `{"state": …}` and nothing else — no title, no
|
||||
body, no labels, no milestone. Closing is not an edit; editing is `pull.py` →
|
||||
change → `push.py --update`.
|
||||
|
||||
**Explicit ids only.** There is no `--milestone` and no `--label`: which issues
|
||||
are finished is a judgement about content, and this script only carries one
|
||||
out, one named id at a time. Deleting an issue is out of scope too — Gitea can,
|
||||
and it is not an operation of this workflow.
|
||||
|
||||
What may be named, and what happens to the local copy:
|
||||
|
||||
| named | resolved through | local file |
|
||||
|---|---|---|
|
||||
| a slug with a file on disk | its `gitea:` field | `state:` rewritten, `synced:` refreshed |
|
||||
| a slug whose file push dropped | `.remote.json` | none to write — say so and move on |
|
||||
| `42`, `#42`, `owner/repo#42`, a URL | the key itself; the ledger supplies the slug | rewritten when a file of that slug is there |
|
||||
| a slug with `origin: local` | — | **refused**: it is not in the tracker, and the error names the id |
|
||||
|
||||
The local file is written only after the tracker has confirmed *this* write: an
|
||||
object carrying the very number that was PATCHed, in the state that was asked
|
||||
for. A non-2xx, a `tea` that would not run, an answer for another issue, a 200
|
||||
that still says `open` — the run stops and the file is byte for byte what it
|
||||
was. `--dry-run` prints the same lines and makes no request at all, so it needs
|
||||
no pinned login.
|
||||
|
||||
Gitea refuses to close an issue that its own dependency graph still blocks. The
|
||||
refusal arrives as a non-2xx with the tracker's own words: close the blockers
|
||||
first, or unlink them in the web UI.
|
||||
|
||||
The index is rebuilt when at least one local file changed, so `INDEX.md` never
|
||||
outlives the state it reports. Nothing is deleted here — unlike a push, a close
|
||||
leaves the working copy where it is.
|
||||
|
||||
## Evicting what the tracker says is closed
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/evict.py --dry-run # ask, report, change nothing
|
||||
python3 <skill-base-dir>/scripts/evict.py # and remove them
|
||||
python3 <skill-base-dir>/scripts/evict.py old-thing # just this one
|
||||
```
|
||||
|
||||
Eviction itself belongs to `/tea:issue` (`issue_evict.py`) and is offline: the
|
||||
decision is `state: closed` plus an `origin:` that names a tracker, both read
|
||||
off the file. This script adds one thing in front of it — a `state:` that is not
|
||||
stale — and then calls that same decision. There is one implementation of "what
|
||||
may be evicted" and it is in the domain.
|
||||
|
||||
Why it exists: a local `state:` is only as fresh as the last pull, so an issue
|
||||
closed in the web UI still reads `open` here and the offline command correctly
|
||||
leaves it alone. The workaround was `pull.py 11 12 13 14 15` — which writes the
|
||||
five closed files back to disk before anything can remove them.
|
||||
|
||||
Order of operations, and it is the safety argument:
|
||||
|
||||
1. every candidate's state is fetched — **all** of them, before anything is
|
||||
removed;
|
||||
2. each answer must be an object carrying the number that was asked about and a
|
||||
state the domain recognizes (`evict.confirmed_state`, the counterpart of
|
||||
`push.confirmed_number`);
|
||||
3. only then does the eviction run.
|
||||
|
||||
**A failed call evicts nothing** — not even the candidates whose answers had
|
||||
already arrived, and no refreshed `state:` is written back either. Stricter than
|
||||
push, which deletes as it goes, and free: evictions have no order between them,
|
||||
so there is no reason to start before every answer is in.
|
||||
|
||||
- A **candidate** is an issue carrying a `gitea:` handle. `origin: local` has
|
||||
none, is never asked about, and is never removed. An `origin: gitea` issue
|
||||
whose handle is missing or unparseable cannot be verified — it is reported on
|
||||
stderr and kept.
|
||||
- No `--repo`: the repo comes from each issue's own handle, so a store holding
|
||||
issues from two repos is checked against both.
|
||||
- One GET per candidate. The store is a working set that push keeps small, and a
|
||||
wrong answer here deletes a file — so each issue is asked about by its own
|
||||
address rather than inferred from a list a `--limit` could have truncated.
|
||||
- A state that disagrees with the file is written back, so the store stops lying
|
||||
about the issues that stay too. `--dry-run` makes no writes at all.
|
||||
- `.remote.json` is not pruned; see [How the slug comes
|
||||
back](#how-the-slug-comes-back) — an evicted issue is exactly as findable as a
|
||||
pushed one.
|
||||
- **`pull.py <n>` still fetches a closed issue.** A number is an address, not a
|
||||
query. A closed issue pulled after an eviction is back on disk, and that is
|
||||
the tracker answering the question it was asked, not a regression.
|
||||
|
||||
## What crosses the boundary, and what does not
|
||||
|
||||
| domain | Gitea | note |
|
||||
|---|---|---|
|
||||
| `id` (slug) | `<!-- tea:id … -->` | first line of the tracker-side body; stripped out of the local copy |
|
||||
| title | `title` | verbatim, both directions |
|
||||
| body | `body` | verbatim up except the marker; verbatim down except the marker and checkbox state, which is unioned |
|
||||
| `state` | `state` | same vocabulary |
|
||||
| `labels` | `labels[]` | names both ways; ids only on write |
|
||||
| `assignees` | `assignees[]` | logins |
|
||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
||||
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, every pull reads them (`--no-deps` opts out) |
|
||||
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
||||
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
||||
|
||||
`depends:` is always slugs. The body's `## Depends on` section is human prose
|
||||
and is passed through **unchanged** in both directions: a pull seeds `depends:`
|
||||
from the `#N` it finds there, a push never rewrites what the author wrote. A
|
||||
translator that edits prose churns the body on every round trip. The edge the
|
||||
tracker acts on is the native link, not the text — which is exactly why the
|
||||
text can be left alone.
|
||||
|
||||
Comments are **pull-only** in the store: `<id>.comments.md` is written by
|
||||
`pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea.
|
||||
|
||||
## Drift
|
||||
|
||||
There is none tracked, and since push started deleting what it sends there is
|
||||
very little left to track. A published issue has **one** copy — Gitea's —
|
||||
except while somebody is working on it, and that window closes at the next
|
||||
push. Nothing watches Gitea, nothing reconciles, nothing warns that a synced
|
||||
issue changed upstream. `synced:` tells you how old your working copy is;
|
||||
`remote-updated:` what the server said at that moment. Re-pull when it matters,
|
||||
and push when you are done so there is nothing to be stale.
|
||||
|
||||
The old question — "I edited this locally, does the server have it, whose text
|
||||
is newer?" — is answered by the store's contents rather than by a mechanism: a
|
||||
file that is here has not been pushed.
|
||||
|
||||
Checkbox state is not an exception to this. The union a pull applies reads only
|
||||
the two bodies in front of it — there is no base version, no history, and no
|
||||
way for it to report that anything diverged. One rule for one line type,
|
||||
precisely so the mechanism this section rules out is not needed.
|
||||
|
||||
## Rich payloads for everything else
|
||||
|
||||
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
|
||||
first and passed as `-d @file`, then kept for a retry or a look at what actually
|
||||
went up. One gitignored directory for all of them, chosen by the transport and
|
||||
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
|
||||
and it is never `tmp/issues/` — a command that touches no issue must not leave
|
||||
an issue store behind.
|
||||
|
||||
Comments and issues are wrapped by the scripts above. For **other** entities
|
||||
(pulls, releases, PATCHing something these scripts do not cover), entity
|
||||
subcommands like `tea pulls create` hang on a large or formatted body — an
|
||||
empty-looking positional triggers the `$EDITOR` fallback on a TTY that does not
|
||||
exist, and the harness eventually kills the process (exit 144 = 128 + SIGURG on
|
||||
macOS). Write the JSON payload to `$PWD/tmp/` first and POST it with
|
||||
`tea api -d @file`. Procedure and endpoint table: `/tea:use`.
|
||||
|
||||
## Login
|
||||
|
||||
Every `tea` call made by hand must carry the literal placeholder
|
||||
`--login "$GITEA_LOGIN"`; the `tea-guard` hook substitutes the operator's pin.
|
||||
Set it with `/tea:auth`. Details in `/tea:use`.
|
||||
@@ -0,0 +1,511 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
_gitea.py — transport. Everything that talks to Gitea, and nothing else.
|
||||
|
||||
Not a command. This module knows logins, HTTP verbs, pagination, and Gitea's
|
||||
query quirks. It does NOT know what an issue is: no sections, no acceptance
|
||||
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
|
||||
lives one layer further out in skills/issue/scripts/issue.py.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
|
||||
Where that file is searched for is NOT written here — skills/auth/scripts/pin.py
|
||||
owns the search order, and the tea-guard hook imports the same module, so `tea`
|
||||
and the scripts can never disagree about which login a directory runs under. No
|
||||
script here accepts a login argument: the operator's pin is the only identity
|
||||
they will use. No pin -> exit with a pointer to /tea:auth.
|
||||
|
||||
Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with
|
||||
a local slug, and the paths of the store-side files this layer writes. All of
|
||||
it is transport bookkeeping, not domain data — the domain never reads any of
|
||||
it, and losing the map still costs a re-pull and not information: the slug it
|
||||
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
|
||||
pull rebuilds the entry from the tracker. See `rebuild_map`.
|
||||
|
||||
Request bodies go to tmp/payload/, which is this module's own scratchpad and
|
||||
NOT a store: nothing in it is anybody's only copy, and writing one must never
|
||||
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
|
||||
touches no issue at all — it used to leave a store behind anyway, because the
|
||||
request file had nowhere else to live. One directory, every caller, resolved
|
||||
from this file the way the two domains resolve theirs.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
REMOTE_MAP = ".remote.json"
|
||||
|
||||
# How far past the ideal page count a `keep`-bounded listing may scan before it
|
||||
# gives up (see list_issues). The ideal is what `limit` would need if every
|
||||
# payload counted; the slack pays for the ones that do not. It is a bound on
|
||||
# requests, deliberately small: "fetch until N are kept" without one is "fetch
|
||||
# the whole tracker" on any repo whose filter matches mostly closed issues.
|
||||
PAGE_SLACK = 4
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# where request bodies land
|
||||
# --------------------------------------------------------------------------
|
||||
# Anchored on THIS FILE, like issue.store_root, so every caller — sync,
|
||||
# whatever comes next — writes to one directory whatever it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
|
||||
# inside somebody's store, because a scratchpad that looks like store contents
|
||||
# is how this went wrong the first time. `tmp/` is already gitignored.
|
||||
|
||||
PAYLOAD_PARTS = ("tmp", "payload")
|
||||
|
||||
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||
# git; the agents-sync hook only ever puts one at a repository root.
|
||||
REPO_MARKERS = (".git", "AGENTS.md")
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def warn(msg):
|
||||
sys.stderr.write("warning: %s\n" % msg)
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
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 payload_root(start=None):
|
||||
"""Absolute path of the request-body scratchpad.
|
||||
|
||||
`start` overrides the anchor so the resolution can be exercised against a
|
||||
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
|
||||
location stands — made absolute so an error can name the directory it
|
||||
really wrote to."""
|
||||
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||
root = repo_root(anchor)
|
||||
if root:
|
||||
return os.path.join(root, *PAYLOAD_PARTS)
|
||||
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
|
||||
|
||||
|
||||
PAYLOAD_ROOT = payload_root()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# login
|
||||
# --------------------------------------------------------------------------
|
||||
# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the
|
||||
# single written copy of the search order, and the tea-guard hook calls the
|
||||
# same function. When the two had a copy each, a git worktree got a hook that
|
||||
# resolved the pin and a transport that did not — in the same directory.
|
||||
#
|
||||
# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those
|
||||
# are anchored on their own file, this is not, and both are right. Where an
|
||||
# installation keeps its files is a fact about the installation; whose login a
|
||||
# project runs under is a fact about the project, and a plugin installed
|
||||
# outside any repository must not answer it from its own directory. See the
|
||||
# module docstring in pin.py.
|
||||
|
||||
_AUTH_SCRIPTS = os.path.abspath(
|
||||
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
|
||||
if _AUTH_SCRIPTS not in sys.path:
|
||||
sys.path.append(_AUTH_SCRIPTS)
|
||||
import pin # noqa: E402
|
||||
|
||||
|
||||
def require_login():
|
||||
"""The operator's pinned login, or exit pointing at /tea:auth.
|
||||
|
||||
No pin found is reported as exactly that. It stays a truthful message: the
|
||||
fix for "the pin is somewhere this search does not reach" belongs in
|
||||
pin.py, never in a hint here that sends the operator to pin it twice."""
|
||||
login, _ = pin.find_pin()
|
||||
if not login:
|
||||
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
||||
return login
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# api
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
||||
allow_fail=False):
|
||||
"""Call `tea api`; return parsed JSON (None on an empty body).
|
||||
|
||||
payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
|
||||
-d @file — the file survives the call for retries and debugging. Where
|
||||
that is, is not the caller's business and never was: the directory is
|
||||
this layer's scratchpad, and the one time it was a caller's decision it
|
||||
got pointed at the issue store. allow_fail returns None instead of
|
||||
exiting when the call fails."""
|
||||
cmd = ["tea", "api", "--login", login]
|
||||
if method != "GET":
|
||||
cmd += ["-X", method]
|
||||
if payload is not None:
|
||||
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
|
||||
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
|
||||
with open(path, "w") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
cmd += ["-d", "@" + path]
|
||||
cmd.append(endpoint)
|
||||
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
if allow_fail:
|
||||
return None
|
||||
die("`tea api %s %s` failed:\n%s" % (method, endpoint, (r.stderr or r.stdout).strip()))
|
||||
body = r.stdout.strip()
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
if allow_fail:
|
||||
return None
|
||||
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
|
||||
|
||||
|
||||
def pages(login, endpoint, limit=50, max_pages=40, **kw):
|
||||
"""GET a list endpoint page by page, yielding each page as it arrives.
|
||||
|
||||
A generator, because a caller whose budget is spent on what it *keeps*
|
||||
cannot be served by a function that fetches everything first: the page after
|
||||
the one that completed the budget must never be requested. Stop consuming
|
||||
and no further request is made."""
|
||||
sep = "&" if "?" in endpoint else "?"
|
||||
for page in range(1, max_pages + 1):
|
||||
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
|
||||
if not isinstance(batch, list) or not batch:
|
||||
return
|
||||
yield batch
|
||||
if len(batch) < limit:
|
||||
return # a short page is the last one
|
||||
|
||||
|
||||
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
|
||||
"""GET a list endpoint page by page; return the concatenated list."""
|
||||
out = []
|
||||
for batch in pages(login, endpoint, limit=limit, max_pages=max_pages, **kw):
|
||||
out.extend(batch)
|
||||
return out
|
||||
|
||||
|
||||
def repo_base(repo=None):
|
||||
"""API prefix. Without --repo, let tea fill {owner}/{repo} from CWD."""
|
||||
return "repos/%s" % repo if repo else "repos/{owner}/{repo}"
|
||||
|
||||
|
||||
def repo_slug(login, repo=None):
|
||||
"""owner/repo as a literal string — needed for remote keys, which must not
|
||||
contain tea's {owner}/{repo} placeholder."""
|
||||
if repo:
|
||||
return repo
|
||||
got = api(login, "repos/{owner}/{repo}", allow_fail=True)
|
||||
if isinstance(got, dict) and got.get("full_name"):
|
||||
return got["full_name"]
|
||||
die("cannot determine owner/repo from the CWD — pass --repo owner/repo")
|
||||
|
||||
|
||||
def parse_key(key):
|
||||
"""Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / a URL."""
|
||||
key = key.strip()
|
||||
m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key)
|
||||
if m:
|
||||
return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2))
|
||||
m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key)
|
||||
if m:
|
||||
return int(m.group(2)), m.group(1)
|
||||
m = re.match(r'^#?(\d+)$', key)
|
||||
if m:
|
||||
return int(m.group(1)), None
|
||||
die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# filters
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def resolve_milestone(login, base, value):
|
||||
"""(id, title) for a milestone given by id or title. Exits if unknown.
|
||||
|
||||
Gitea silently IGNORES an unresolvable `milestones=` filter and returns the
|
||||
whole backlog, so the milestone must be resolved before it is trusted."""
|
||||
got = paginate(login, "%s/milestones?state=all" % base, limit=100)
|
||||
for m in got or []:
|
||||
if str(m.get("id")) == str(value) or m.get("title") == str(value):
|
||||
return m["id"], m.get("title", "")
|
||||
have = ", ".join("%s (id %d)" % (m.get("title", ""), m["id"]) for m in got or [])
|
||||
die("no milestone %r in this repo — have: %s" % (value, have or "none"))
|
||||
|
||||
|
||||
def matches(payload, milestone_id=None, labels=()):
|
||||
"""Client-side re-check of a server-side filter — see resolve_milestone."""
|
||||
if payload.get("pull_request"):
|
||||
return False
|
||||
if milestone_id is not None and (payload.get("milestone") or {}).get("id") != milestone_id:
|
||||
return False
|
||||
names = {l.get("name", "") for l in payload.get("labels") or []}
|
||||
return all(l in names for l in labels)
|
||||
|
||||
|
||||
def list_issues(login, base, state="open", labels=(), query=None,
|
||||
milestone=None, limit=100, keep=None):
|
||||
"""Filtered issue payloads. Returns (payloads, milestone_title).
|
||||
|
||||
One request per page, and the payload already carries the issue bodies — a
|
||||
whole milestone costs one call per 50 issues, not one per issue.
|
||||
|
||||
`limit` counts the payloads the CALLER cares about, not the ones the server
|
||||
returned. Without `keep` those are the same thing and this behaves as it
|
||||
always did. With it, `keep(payload)` says whether a payload counts, pages
|
||||
keep coming until `limit` of them have, and the returned list carries the
|
||||
ones that did not count too — they were enumerated, and a caller that has
|
||||
something to say about them (pull.py: "N closed, not stored") still can.
|
||||
|
||||
What `keep` means is the caller's business; this module only counts. Two
|
||||
boundaries hold whatever it decides:
|
||||
|
||||
- **Stop at the limit.** The page after the one that completed the budget
|
||||
is not requested — `pages` is a generator and this loop returns out of it.
|
||||
- **Stop at the page budget.** A predicate that rejects everything must not
|
||||
turn a bounded read into a walk of the whole tracker, so a filtered read
|
||||
may scan at most `PAGE_SLACK` times the pages `limit` would need if every
|
||||
payload counted. Hitting that with an unfilled budget is a warning, not a
|
||||
silent short answer: the caller asked for N and is told it got fewer."""
|
||||
if limit < 1:
|
||||
die("--limit must be 1 or more, got %d" % limit)
|
||||
ms_id, ms_title = (None, None)
|
||||
if milestone is not None:
|
||||
ms_id, ms_title = resolve_milestone(login, base, milestone)
|
||||
|
||||
params = {"state": state, "type": "issues"}
|
||||
if labels:
|
||||
params["labels"] = ",".join(labels)
|
||||
if query:
|
||||
params["q"] = query
|
||||
if ms_title:
|
||||
params["milestones"] = ms_title
|
||||
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
|
||||
|
||||
per_page = min(limit, 50)
|
||||
ideal = max(1, -(-limit // per_page))
|
||||
budget = ideal if keep is None else ideal * PAGE_SLACK
|
||||
|
||||
got, kept, seen_pages, last_full = [], 0, 0, False
|
||||
for batch in pages(login, endpoint, limit=per_page, max_pages=budget):
|
||||
seen_pages += 1
|
||||
last_full = len(batch) == per_page
|
||||
for p in batch:
|
||||
if not matches(p, ms_id, labels):
|
||||
continue
|
||||
got.append(p)
|
||||
if keep is None or keep(p):
|
||||
kept += 1
|
||||
if kept >= limit:
|
||||
return got, ms_title
|
||||
if keep is not None and seen_pages >= budget and last_full:
|
||||
warn("scanned %d page(s) and stopped %d short of --limit %d — there may"
|
||||
" be more; narrow the filter or raise --limit" % (budget, limit - kept, limit))
|
||||
return got, ms_title
|
||||
|
||||
|
||||
def get_issue(login, base, number):
|
||||
payload = api(login, "%s/issues/%d" % (base, number))
|
||||
if not isinstance(payload, dict) or "number" not in payload:
|
||||
die("issue #%d not found" % number)
|
||||
return payload
|
||||
|
||||
|
||||
def get_comments(login, base, number):
|
||||
return paginate(login, "%s/issues/%d/comments" % (base, number))
|
||||
|
||||
|
||||
def native_deps(login, base, number):
|
||||
"""Gitea's own issue-dependency links; empty when unsupported."""
|
||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
|
||||
return [i["number"] for i in got] if isinstance(got, list) else []
|
||||
|
||||
|
||||
def native_dep_pairs(login, base, number):
|
||||
"""The same links as {(owner/repo, number)} — what a repeat push compares
|
||||
against so it does not POST a link the tracker already has.
|
||||
|
||||
A bare number is ambiguous the moment a dependency lives in another repo,
|
||||
and IssueMeta lets it, so the repo travels with it. The pair is a transport
|
||||
fact; formatting it as `owner/repo#42` is map.py's job, not this module's."""
|
||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
|
||||
out = set()
|
||||
for i in got if isinstance(got, list) else []:
|
||||
repo = (i.get("repository") or {}).get("full_name") or ""
|
||||
if "number" in i:
|
||||
out.add((repo, int(i["number"])))
|
||||
return out
|
||||
|
||||
|
||||
def add_dependency(login, base, number, dep_repo, dep_number):
|
||||
"""Make issue `number` depend on `dep_repo#dep_number`. True on success.
|
||||
|
||||
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
|
||||
|
||||
POST /repos/{owner}/{repo}/issues/{index}/dependencies
|
||||
body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
|
||||
"Make the issue in the url depend on the issue in the form."
|
||||
|
||||
The URL names the blocked issue and the body the blocker, which is the same
|
||||
direction native_deps reads back ("all issues that block this issue"). A
|
||||
link that already exists answers 409, so a failure here is reported and not
|
||||
fatal: one missing cross-link must not abort a push that has already
|
||||
created issues. Callers pre-filter with native_dep_pairs."""
|
||||
owner, _, name = (dep_repo or "").partition("/")
|
||||
if not owner or not name:
|
||||
return False
|
||||
payload = {"index": int(dep_number), "owner": owner, "repo": name}
|
||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
|
||||
payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
|
||||
return got is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# labels
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def ensure_labels(login, base, specs, root):
|
||||
"""Map label name -> id, creating what the repo is missing.
|
||||
|
||||
`specs` is {name: {"color", "description", "exclusive"}} handed in by the
|
||||
caller — this module does not know which namespaces are exclusive or what
|
||||
they mean. Cached in <root>/.labels.json; the cache is refreshed from the
|
||||
API before anything is created."""
|
||||
cache_path = os.path.join(root, ".labels.json")
|
||||
cache = {}
|
||||
if os.path.isfile(cache_path):
|
||||
try:
|
||||
with open(cache_path) as f:
|
||||
cache = json.load(f)
|
||||
except Exception:
|
||||
cache = {}
|
||||
|
||||
if any(n not in cache for n in specs):
|
||||
cache = {l["name"]: l["id"] for l in paginate(login, "%s/labels" % base, limit=100)}
|
||||
|
||||
for name, spec in specs.items():
|
||||
if name in cache:
|
||||
continue
|
||||
payload = dict(spec, name=name)
|
||||
created = api(login, "%s/labels" % base, "POST", payload,
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
if not created or "id" not in created:
|
||||
die("could not create label %r" % name)
|
||||
cache[name] = created["id"]
|
||||
sys.stderr.write("created label %s%s\n"
|
||||
% (name, " (exclusive)" if spec.get("exclusive") else ""))
|
||||
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(cache_path, "w") as f:
|
||||
json.dump(cache, f, indent=2, sort_keys=True)
|
||||
return {n: cache[n] for n in specs}
|
||||
|
||||
|
||||
def resolve_milestone_id(login, base, title):
|
||||
"""Milestone id for a title, or None when the repo has no such milestone."""
|
||||
if not title or title == "none":
|
||||
return None
|
||||
for m in paginate(login, "%s/milestones?state=all" % base, limit=100) or []:
|
||||
if m.get("title") == title:
|
||||
return m["id"]
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# store-side files this layer owns
|
||||
# --------------------------------------------------------------------------
|
||||
# The issue file itself is the domain's (`issue.path_of`). The one file the sync
|
||||
# layer puts beside it is named here, in one place, because three commands have
|
||||
# to agree on it: pull.py writes the thread, comment.py refetches it, push.py
|
||||
# deletes it along with the issue it just sent.
|
||||
|
||||
def comments_path(root, id):
|
||||
"""An issue's comment thread — beside it, under the same slug.
|
||||
|
||||
A path, not a concept the domain needs: a thread is pulled from Gitea and
|
||||
never pushed back, so the domain has no reason to know the file exists."""
|
||||
return os.path.join(root, "%s.comments.md" % id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# id map: remote key <-> local slug
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def map_path(root):
|
||||
return os.path.join(root, REMOTE_MAP)
|
||||
|
||||
|
||||
def load_map(root):
|
||||
"""{"owner/repo#42": "wire-sqlc-appclick"} — the local slug ledger.
|
||||
|
||||
Entries outlive the files they name, and that is now the normal case rather
|
||||
than a leak: `push.py` deletes an issue's file the moment Gitea confirms it,
|
||||
and the entry it leaves behind is what lets the next `pull.py 42` land on
|
||||
the same slug. Nothing prunes them, because "no file" no longer means "no
|
||||
such issue". A stale entry costs one json line and is corrected the next
|
||||
time that number is pulled."""
|
||||
p = map_path(root)
|
||||
if not os.path.isfile(p):
|
||||
return {}
|
||||
try:
|
||||
with open(p) as f:
|
||||
got = json.load(f)
|
||||
return got if isinstance(got, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_map(root, m):
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(map_path(root), "w") as f:
|
||||
json.dump(m, f, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def rebuild_map(root, issues):
|
||||
"""Fold the `gitea:` fields still on disk into the id map. Returns it.
|
||||
|
||||
This used to say "the files are the source of truth; .remote.json is only an
|
||||
index over them", and that stopped being true the day push started deleting
|
||||
the file it had just sent. A pushed issue leaves no `gitea:` field behind to
|
||||
read, so the files are now a SUBSET of what the map knows, and a rebuild
|
||||
from them alone would throw away every entry it cannot see.
|
||||
|
||||
So the contradiction is resolved by moving the source of truth, not by
|
||||
keeping this function honest about files:
|
||||
|
||||
Gitea the issue, and — in `<!-- tea:id … -->` — its slug
|
||||
.remote.json a local number -> slug ledger, a cache of that marker
|
||||
tmp/issues/*.md whatever happens to be checked out right now
|
||||
|
||||
Which makes this a MERGE and never a replacement: it starts from what is
|
||||
already recorded and adds what the remaining files say. What it cannot
|
||||
recover — a pushed-and-dropped issue whose ledger entry was also lost — is
|
||||
not lost either; the next `pull.py <n>` reads the slug off the marker and
|
||||
writes the entry back."""
|
||||
m = load_map(root)
|
||||
for id, iss in issues.items():
|
||||
key = iss.extra.get("gitea")
|
||||
if key:
|
||||
m[key] = id
|
||||
save_map(root, m)
|
||||
return m
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
close.py — change an issue's state in Gitea, and in the local copy with it.
|
||||
|
||||
The one regular tracker operation that used to have no script: closing. Without
|
||||
it the only way to move `state:` was a raw `tea api -X PATCH -d '{"state":
|
||||
"closed"}' repos/OWNER/REPO/issues/N`, which spells out the owner, the repo and
|
||||
the request body — the three things `_gitea.py` exists to hide — and which needs
|
||||
`Bash(tea api *)`, a permission that also covers `-X DELETE` on the repository.
|
||||
|
||||
close.py wire-sqlc-appclick one issue, by slug
|
||||
close.py wire-sqlc-appclick 42 #43 several, by slug or number
|
||||
close.py --reopen 42 the same thing backwards
|
||||
close.py --dry-run 42 43 what would happen, no request at all
|
||||
|
||||
STATE ONLY. This script sends `{"state": …}` and nothing else: no title, no
|
||||
body, no labels, no milestone. Editing an issue is `pull.py` -> edit ->
|
||||
`push.py --update`; closing it is not an edit.
|
||||
|
||||
**What may be named.** A local slug, or a Gitea key (`42`, `#42`,
|
||||
`owner/repo#42`, an issue URL) — the same forms `pull.py` takes. Both are
|
||||
needed, and for the same reason: a push deletes the local file, so most issues
|
||||
in the tracker have no slug on disk to name them by. A slug is resolved through
|
||||
the file's `gitea:` field when the file is there, and through the ledger
|
||||
(`.remote.json`) when push has already dropped it.
|
||||
|
||||
**An `origin: local` issue cannot be closed.** It is not in the tracker, so
|
||||
there is nothing to close there, and the run stops naming the id rather than
|
||||
quietly editing one field of a local file. Delete it, or push it first.
|
||||
|
||||
**Explicit ids only.** No `--milestone`, no `--label`, no "close everything
|
||||
that looks done". Which issues are finished is a judgement about content; this
|
||||
script only carries it out, one named id at a time. Nothing here deletes an
|
||||
issue either — Gitea can, and it is not an operation of this workflow.
|
||||
|
||||
The local file is written only after the tracker has confirmed the write:
|
||||
|
||||
1. `tea` ran and exited 0 (a non-2xx exits the run inside `_gitea.api`), and
|
||||
2. the answer is an object carrying the very number that was PATCHed, and
|
||||
3. its `state` is the state we asked for.
|
||||
|
||||
Anything else and the file is left exactly as it was — see `confirmed`. An
|
||||
issue whose local copy is gone (pushed and dropped) is closed in Gitea and
|
||||
nothing is written; the state comes down with the next `pull.py`.
|
||||
|
||||
Gitea refuses to close an issue that its own dependency graph still blocks. That
|
||||
refusal arrives as a non-2xx and stops the run with the tracker's own words:
|
||||
close the blockers first, or unlink them in the web UI.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
# What `_gitea.parse_key` accepts, asked as a question instead of an assertion:
|
||||
# parse_key exits on anything it cannot read, and here "not a key" is the normal
|
||||
# case — it means the argument is a slug. A slug never contains `#`, `/` or `:`,
|
||||
# so the two vocabularies cannot collide.
|
||||
KEY_RE = re.compile(r'^(#?\d+|[\w.-]+/[\w.-]+#\d+|https?://\S+)$')
|
||||
|
||||
|
||||
def looks_like_key(arg):
|
||||
return bool(KEY_RE.match((arg or "").strip()))
|
||||
|
||||
|
||||
def ledger_pairs(remote_map, repo=None):
|
||||
"""[(repo, number, slug)] from `.remote.json`, filtered to `repo`.
|
||||
|
||||
A `--repo` that was not given means "whatever the ledger holds": resolving
|
||||
the repo's real name costs a request, and a dry run is required to make
|
||||
none. The ambiguity that opens — one number under two repos — is caught at
|
||||
lookup time rather than papered over."""
|
||||
out = []
|
||||
for key, slug in sorted(remote_map.items()):
|
||||
r, n = gmap.parse_remote_key(key)
|
||||
if n:
|
||||
if repo is None or r == repo:
|
||||
out.append((r, n, slug))
|
||||
return out
|
||||
|
||||
|
||||
def one(candidates, what, arg):
|
||||
"""The single `(repo, value)` in `candidates`, None when empty, or exit.
|
||||
|
||||
Two answers mean the ledger knows this number (or this slug) under more than
|
||||
one repository, and only `--repo` can settle that."""
|
||||
got = sorted(set(candidates))
|
||||
if len(got) > 1:
|
||||
_gitea.die("%r matches %s under more than one repo (%s) — pass "
|
||||
"--repo owner/repo" % (arg, what, ", ".join(r for r, _v in got)))
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
def resolve(arg, issues, pairs):
|
||||
"""(id, number, repo) for one argument. Either of `id` and `repo` is None
|
||||
when nothing this machine holds names it.
|
||||
|
||||
Order, and it is the order of what is most authoritative about this machine:
|
||||
a file on disk, then the ledger, then nothing. A key skips straight to the
|
||||
ledger — its number is already the tracker's answer, and the slug is only
|
||||
wanted so the local copy, if there is one, can be kept honest.
|
||||
|
||||
`repo` travels out with the number because a key may name one
|
||||
(`owner/repo#42`) and a `gitea:` field always does. Sending a foreign key to
|
||||
whatever repo the CWD happens to be in would close somebody else's issue of
|
||||
the same number, so the caller reconciles them before anything goes out."""
|
||||
if looks_like_key(arg):
|
||||
number, repo = _gitea.parse_key(arg)
|
||||
hit = one([(r, s) for r, n, s in pairs
|
||||
if n == number and (repo is None or r == repo)], "a slug", arg)
|
||||
return (hit[1] if hit else None), number, repo or (hit[0] if hit else None)
|
||||
|
||||
iss = issues.get(arg)
|
||||
if iss is not None:
|
||||
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
||||
if not number:
|
||||
_gitea.die("%s is not in the tracker (origin: %s, no gitea: field) — "
|
||||
"there is no state there to change; push.py %s first"
|
||||
% (arg, iss.origin, arg))
|
||||
return arg, number, repo
|
||||
|
||||
hit = one([(r, n) for r, n, s in pairs if s == arg], "a number", arg)
|
||||
if hit:
|
||||
return arg, hit[1], hit[0] # pushed, and its file went with the push
|
||||
_gitea.die("no issue %r in the store or the ledger — pass a Gitea number "
|
||||
"(42, #42, owner/repo#42, a URL) to close one this machine has "
|
||||
"never seen" % arg)
|
||||
|
||||
|
||||
def confirmed(got, number, state):
|
||||
"""True when the tracker's answer confirms THIS write, and nothing else.
|
||||
|
||||
The gate in front of the local write, and deliberately boring: an answer
|
||||
counts only when it is an object carrying the very number that was PATCHed
|
||||
(`bool` rejected explicitly — `True` is an `int`) and the state that was
|
||||
asked for. A non-2xx and a `tea` that would not run never reach here at all;
|
||||
`_gitea.api` exits on both, so the file survives those by never being
|
||||
written."""
|
||||
if not isinstance(got, dict):
|
||||
return False
|
||||
n = got.get("number")
|
||||
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
||||
return False
|
||||
return got.get("state") == state
|
||||
|
||||
|
||||
def apply_state(root, iss, state, got):
|
||||
"""Write the confirmed state onto the local file; return its path.
|
||||
|
||||
`state:` is the domain's own field, so it is set on the issue and written
|
||||
out by the domain's own writer. The sync-owned freshness fields travel with
|
||||
it: the answer that authorized this write is also the newest thing the
|
||||
tracker has said about the issue, so `synced:` and `remote-updated:` are
|
||||
stamped from it rather than left describing an older read."""
|
||||
iss.state = state
|
||||
iss.extra["synced"] = _gitea.now_iso()
|
||||
if got.get("updated_at"):
|
||||
iss.extra["remote-updated"] = got["updated_at"]
|
||||
return issue.save(root, iss)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Close (or reopen) issues in Gitea")
|
||||
ap.add_argument("ids", nargs="+",
|
||||
help="local ids, or Gitea keys: 42, #42, owner/repo#42, URL")
|
||||
ap.add_argument("--reopen", action="store_true",
|
||||
help="set the state back to open instead of closed")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print what would change; makes no request at all")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = args.out
|
||||
state = "open" if args.reopen else "closed"
|
||||
verb = "reopen" if args.reopen else "close"
|
||||
past = "reopened" if args.reopen else "closed"
|
||||
|
||||
# A store that is not there is not an error here: a number needs no local
|
||||
# file, and closing an issue whose copy was dropped by push is the normal
|
||||
# case. `load_all` reads an absent directory as an empty one.
|
||||
issues = issue.load_all(root)
|
||||
pairs = ledger_pairs(_gitea.load_map(root), args.repo)
|
||||
|
||||
# Every argument is resolved before anything is sent, so a typo in the third
|
||||
# id does not leave the first two closed.
|
||||
targets = []
|
||||
for arg in args.ids:
|
||||
got = resolve(arg, issues, pairs)
|
||||
if got not in targets:
|
||||
targets.append(got)
|
||||
|
||||
# One run, one repo. An explicit --repo is the operator's word and wins;
|
||||
# without one, the repo comes from what the ids themselves said, and two
|
||||
# answers are a question rather than a guess — `repo_base` would otherwise
|
||||
# let `tea` fill the blank from the CWD and close the wrong #42.
|
||||
named = {r for _i, _n, r in targets if r}
|
||||
if not args.repo and len(named) > 1:
|
||||
_gitea.die("all ids must belong to one repo, got: %s" % ", ".join(sorted(named)))
|
||||
repo_arg = args.repo or (sorted(named)[0] if named else None)
|
||||
|
||||
if args.dry_run:
|
||||
for id, number, _repo in targets:
|
||||
iss = issues.get(id)
|
||||
where = ("%s (state: %s)" % (issue.path_of(root, id), iss.state)
|
||||
if iss is not None else "no local copy")
|
||||
print("would %s %s #%d — %s" % (verb, id or "?", number, where))
|
||||
print("%d issue(s) would be %s; no request was made"
|
||||
% (len(targets), past))
|
||||
return
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(repo_arg)
|
||||
|
||||
touched = 0
|
||||
for id, number, _repo in targets:
|
||||
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH",
|
||||
{"state": state}, payload_name="state-%d" % number)
|
||||
# The gate. Above it nothing local has been written; below it the file
|
||||
# is about to say something the tracker had better agree with.
|
||||
if not confirmed(got, number, state):
|
||||
_gitea.die("#%d: %s failed — the tracker's answer does not confirm the "
|
||||
"write (%.200r). Nothing local was changed."
|
||||
% (number, verb, got))
|
||||
|
||||
print("%s %s #%d %s" % (past, id or "?", number,
|
||||
got.get("html_url", "")))
|
||||
|
||||
iss = issues.get(id)
|
||||
if iss is None:
|
||||
print(" no local copy — pull.py %d to get one" % number)
|
||||
continue
|
||||
print(" state: %s %s" % (state, apply_state(root, iss, state, got)))
|
||||
touched += 1
|
||||
|
||||
if touched:
|
||||
path, n = issue_index.build(root)
|
||||
print("index: %s — %d issue(s)" % (path, n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -43,10 +43,13 @@ def main():
|
||||
ap.add_argument("--edit", type=int, metavar="COMMENT_ID",
|
||||
help="PATCH an existing comment instead of posting a new one")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = args.out
|
||||
if not issue.store_exists(root):
|
||||
_gitea.die("store %s does not exist — nothing was created" % root)
|
||||
if not os.path.isfile(issue.path_of(root, args.id)):
|
||||
_gitea.die("no issue %r in %s" % (args.id, root))
|
||||
iss = issue.load(root, args.id)
|
||||
@@ -70,13 +73,11 @@ def main():
|
||||
|
||||
if args.edit:
|
||||
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
|
||||
{"body": body}, payload_name="comment-%d" % args.edit,
|
||||
out_root=root)
|
||||
{"body": body}, payload_name="comment-%d" % args.edit)
|
||||
verb = "edited"
|
||||
else:
|
||||
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
|
||||
{"body": body}, payload_name="comment-%s" % args.id,
|
||||
out_root=root)
|
||||
{"body": body}, payload_name="comment-%s" % args.id)
|
||||
verb = "posted"
|
||||
if not isinstance(got, dict) or "id" not in got:
|
||||
_gitea.die("%s failed, unexpected response" % verb)
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
evict.py — ask Gitea which stored issues are closed, then evict those.
|
||||
|
||||
evict.py check every synced issue in the store, evict the
|
||||
ones Gitea says are closed
|
||||
evict.py old-thing … only these
|
||||
evict.py --dry-run ask, report, change nothing
|
||||
|
||||
The offline command is `/tea:issue`'s `issue_evict.py`, and it is the one that
|
||||
decides and deletes — this script adds exactly one thing in front of it: a
|
||||
`state:` that is not stale. A local `state:` is only as fresh as the last pull,
|
||||
so an issue closed in the web UI an hour ago still reads `open` here and the
|
||||
offline command will (correctly) leave it alone. That is the gap this closes,
|
||||
and it is the observed workflow: before this existed the operator had to
|
||||
`pull.py 11 12 13 14 15` first, which re-wrote the five closed files onto disk
|
||||
before anything could remove them.
|
||||
|
||||
Order of operations, and it is the whole safety argument:
|
||||
|
||||
1. every candidate's state is fetched — ALL of them, before anything is
|
||||
removed;
|
||||
2. each answer must be an object carrying the number we asked about and a
|
||||
state from the domain's own vocabulary (`confirmed_state`);
|
||||
3. only then is the eviction run, by handing the refreshed issues to
|
||||
`issue_evict.run` — the same decision, the same deletion, the same
|
||||
protection of `origin: local`, in one place.
|
||||
|
||||
A `tea` that will not run, a non-2xx, an answer for another issue, a state
|
||||
nobody recognizes: the run stops at step 2 and NOTHING is deleted, not even the
|
||||
issues whose answers had already arrived. That is stricter than `push.py`, which
|
||||
deletes as it goes, and it costs nothing here — there is no ordering constraint
|
||||
between evictions, so there is no reason to start before every answer is in.
|
||||
|
||||
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
|
||||
none, is never asked about, and is never evicted — it is not in the tracker to
|
||||
be closed. An `origin: gitea` issue whose handle is missing or unparseable
|
||||
cannot be verified, so it is reported and kept rather than guessed at.
|
||||
|
||||
Cost: one GET per candidate. The store is a working set that push keeps small,
|
||||
and a wrong answer here deletes a file, so each issue is asked about by its own
|
||||
address rather than inferred from a list that a `--limit` could have truncated.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_evict # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def candidates(issues, ids=None):
|
||||
"""(checkable, unverifiable) — which issues the tracker can be asked about.
|
||||
|
||||
checkable is [(id, repo, number)] read off the `gitea:` handle, so an issue
|
||||
that lives in another repo is asked about there. unverifiable is
|
||||
[(id, why)]: it names a tracker but carries no handle to reach it by, which
|
||||
is a file to report, never one to delete on a guess.
|
||||
|
||||
An `origin: local` issue is in neither list. It has no handle because it has
|
||||
never left this machine, and asking Gitea about it is not a question that
|
||||
has an answer.
|
||||
"""
|
||||
checkable, unverifiable = [], []
|
||||
for id in (list(ids) if ids else sorted(issues)):
|
||||
iss = issues[id]
|
||||
if iss.is_local:
|
||||
continue
|
||||
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
||||
if not repo or not number:
|
||||
unverifiable.append((id, "origin: %s but no usable `gitea:` handle"
|
||||
% iss.origin))
|
||||
continue
|
||||
checkable.append((id, repo, number))
|
||||
return checkable, unverifiable
|
||||
|
||||
|
||||
def confirmed_state(got, number):
|
||||
"""The state Gitea confirmed for `number`, or None — the deletion gate.
|
||||
|
||||
The counterpart of `push.confirmed_number`, and written the same way: boring,
|
||||
and saying no by default, because everything downstream of a `str` return
|
||||
here may delete a file. An answer counts only when it is a dict, carries the
|
||||
very number we asked about, and names a state the domain recognizes.
|
||||
|
||||
`bool` is rejected explicitly: `True` is an `int` in Python, and an answer
|
||||
about issue `true` is not an answer about issue 42.
|
||||
|
||||
What it does not have to catch, because it never gets here: a non-2xx or a
|
||||
`tea` that would not run at all — `_gitea.api` exits on both.
|
||||
"""
|
||||
if not isinstance(got, dict):
|
||||
return None
|
||||
n = got.get("number")
|
||||
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
||||
return None
|
||||
state = got.get("state")
|
||||
return state if state in issue.STATES else None
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Evict issues Gitea reports as closed from the local store")
|
||||
ap.add_argument("ids", nargs="*",
|
||||
help="issue ids (default: every synced issue in the store)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="ask the tracker and report; write and delete nothing")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
root = args.out
|
||||
if not issue.store_exists(root):
|
||||
_gitea.die("store %s does not exist — nothing to evict" % root)
|
||||
issues = issue.load_all(root)
|
||||
missing = [i for i in args.ids if i not in issues]
|
||||
if missing:
|
||||
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
||||
|
||||
checkable, unverifiable = candidates(issues, args.ids)
|
||||
for id, why in unverifiable:
|
||||
_gitea.warn("%s: %s — kept, and not asked about" % (id, why))
|
||||
if not checkable:
|
||||
print("nothing to check: no issue in the store carries a `gitea:` handle")
|
||||
return 0
|
||||
|
||||
login = _gitea.require_login()
|
||||
|
||||
# ---- every answer first, deletions after -----------------------------
|
||||
fresh = {}
|
||||
for id, repo, number in checkable:
|
||||
got = _gitea.api(login, "%s/issues/%d" % (_gitea.repo_base(repo), number))
|
||||
state = confirmed_state(got, number)
|
||||
if state is None:
|
||||
_gitea.die("%s: the tracker's answer for %s#%d does not confirm a state "
|
||||
"(%.200r). Nothing was evicted."
|
||||
% (id, repo, number, got))
|
||||
fresh[id] = state
|
||||
|
||||
# The store stops lying even about the issues that stay: an answer already
|
||||
# paid for is written back when it disagrees with the file. This is the only
|
||||
# write this script makes, and a dry run makes none.
|
||||
for id, state in sorted(fresh.items()):
|
||||
was = issues[id].state
|
||||
if was == state:
|
||||
continue
|
||||
print("state %s %s -> %s" % (id, was, state))
|
||||
issues[id].state = state
|
||||
if not args.dry_run:
|
||||
issue.save(root, issues[id])
|
||||
|
||||
issue_evict.run(root, issues, [id for id, _, _ in checkable], args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
labels.py — put the canonical label set into a repository, in one run.
|
||||
|
||||
Every `type/*` and every `severity/*` the domain taxonomy defines, created up
|
||||
front instead of trickling in as a side effect of whichever push first happens
|
||||
to use one. Until a name exists in the repository nobody can filter by it in
|
||||
the web UI, so somebody makes their own with a foreign color and without
|
||||
`exclusive`, and the set arrives in pieces over months.
|
||||
|
||||
labels.py --dry-run print the plan, write nothing
|
||||
labels.py create whatever is missing
|
||||
labels.py --fix also patch color / `exclusive` drift
|
||||
labels.py --repo owner/repo outside the repository's own checkout
|
||||
|
||||
No label name is spelled out in this file. The names are assembled from the
|
||||
domain — issue.TYPES, issue.SEVERITIES, issue.EXCLUSIVE_NS — and painted by
|
||||
map.label_specs; add a type over in skills/issue and the next run creates it.
|
||||
`tea labels create` cannot set `exclusive` (tea 0.14.2), so creation goes
|
||||
through `tea api`.
|
||||
|
||||
The repository's own labels are read before anything is written. A name that
|
||||
matches exactly is left alone — never re-created, never patched; a color or
|
||||
`exclusive` that disagrees with the spec is reported, and corrected only under
|
||||
--fix. A name that merely RESEMBLES a canonical one (the same tail, up to
|
||||
case, separator and whatever namespace is in front: `X`, `x`, `kind/x`,
|
||||
`type: x` against `type/x`) is reported with its id and never touched —
|
||||
renaming somebody else's label is a decision, not a step.
|
||||
|
||||
Out of scope by design: `tech/*` and `comp/*`, which are open-ended and get
|
||||
created by push as they come up, and deleting or renaming anything at all.
|
||||
Only repository labels are read; an organization's own labels sit behind a
|
||||
different endpoint and are neither read nor written.
|
||||
|
||||
The issue store is out of scope too, and not incidentally. A label belongs to
|
||||
the repository, not to any issue, so this command neither reads tmp/issues/ nor
|
||||
creates it — the taxonomy it paints comes from the domain MODULE, and the
|
||||
request bodies it sends go to the transport's own tmp/payload/.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the canonical set
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Which taxonomy collection fills which exclusive namespace. Both sides are the
|
||||
# domain's — this dict is only the join between them, and it is the whole
|
||||
# reason no name has to be repeated here.
|
||||
MEMBERS = {"type/": issue.TYPES, "severity/": issue.SEVERITIES}
|
||||
|
||||
|
||||
def canonical_names():
|
||||
"""Every name in the canonical set, in taxonomy order.
|
||||
|
||||
Which namespaces are exclusive is issue.EXCLUSIVE_NS; what lives in each
|
||||
is MEMBERS, i.e. the domain again. A namespace the domain declares but
|
||||
MEMBERS does not know about is handed back separately — better reported
|
||||
than quietly missing from the set."""
|
||||
names, orphan = [], []
|
||||
for ns in issue.EXCLUSIVE_NS:
|
||||
if ns in MEMBERS:
|
||||
names += [ns + m for m in MEMBERS[ns]]
|
||||
else:
|
||||
orphan.append(ns)
|
||||
return names, orphan
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# lookalikes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
WORDS = re.compile(r'[^a-z0-9]+')
|
||||
|
||||
|
||||
def akin(name):
|
||||
"""Comparison keys for a label name: its tail, and the whole name squashed.
|
||||
|
||||
Case, separators and the namespace in front are noise — what a person
|
||||
meant is the tail. `x`, `X`, `kind/x` all reduce to the same tail as
|
||||
`type/x`, and `severity: x y` to the same squashed form as `severity/xy`.
|
||||
Two names resemble each other when these sets intersect."""
|
||||
parts = [p for p in WORDS.split(name.lower()) if p]
|
||||
return {parts[-1], "".join(parts)} if parts else set()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# plan
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def color_of(value):
|
||||
"""Gitea reports colors bare, map.py writes them with a `#`. Same color."""
|
||||
return (value or "").lstrip("#").lower()
|
||||
|
||||
|
||||
def drift_of(spec, got):
|
||||
"""Where an existing label disagrees with the spec, as (field, is, want).
|
||||
|
||||
Only color and `exclusive` — a description somebody rewrote is theirs, and
|
||||
the name matched exactly or we would not be here."""
|
||||
out = []
|
||||
if color_of(got.get("color")) != color_of(spec.get("color")):
|
||||
out.append(("color", color_of(got.get("color")), color_of(spec.get("color"))))
|
||||
if bool(got.get("exclusive")) != bool(spec.get("exclusive")):
|
||||
out.append(("exclusive", str(bool(got.get("exclusive"))).lower(),
|
||||
str(bool(spec.get("exclusive"))).lower()))
|
||||
return out
|
||||
|
||||
|
||||
def plan(specs, existing):
|
||||
"""(rows, similar) for one repository, decided before anything is written.
|
||||
|
||||
A row is (name, spec, got, drift), one per canonical label in taxonomy
|
||||
order: `got` is the repository's own payload when that exact name is
|
||||
already there (None when it is not), `drift` what disagrees with the spec.
|
||||
|
||||
`similar` is (name, id, [canonical it resembles]) for the repository's
|
||||
other labels. They are reported and left alone: this script owns the
|
||||
canonical names, not everything that looks like one."""
|
||||
by_name = dict((l.get("name", ""), l) for l in existing or [])
|
||||
|
||||
rows = []
|
||||
for name in specs:
|
||||
got = by_name.get(name)
|
||||
rows.append((name, specs[name], got, drift_of(specs[name], got) if got else []))
|
||||
|
||||
keys = dict((name, akin(name)) for name in specs)
|
||||
similar = []
|
||||
for l in existing or []:
|
||||
name = l.get("name", "")
|
||||
if name in specs:
|
||||
continue
|
||||
mine = akin(name)
|
||||
hits = [n for n in specs if keys[n] & mine]
|
||||
if hits:
|
||||
similar.append((name, l.get("id"), hits))
|
||||
return rows, similar
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# run
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Create the canonical type/* and severity/* labels in a repository")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print the plan; not one writing request")
|
||||
ap.add_argument("--fix", action="store_true",
|
||||
help="also patch color/exclusive on labels that already exist")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
args = ap.parse_args()
|
||||
|
||||
names, orphan = canonical_names()
|
||||
for ns in orphan:
|
||||
_gitea.warn("namespace %r is exclusive in the domain but has no members here "
|
||||
"— nothing created for it" % ns)
|
||||
specs = gmap.label_specs(names)
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(args.repo)
|
||||
|
||||
# Read first, always: the plan is decided against the repository itself,
|
||||
# never against tmp/issues/.labels.json. That cache is what makes
|
||||
# _gitea.ensure_labels cheap for push.py and wrong for a bootstrap — it
|
||||
# answers "what did we create last time", and the answer here has to be
|
||||
# "what does the repository have right now".
|
||||
existing = _gitea.paginate(login, "%s/labels" % base, limit=100)
|
||||
rows, similar = plan(specs, existing)
|
||||
|
||||
fixed, drifted = 0, 0
|
||||
for name, spec, got, drift in rows:
|
||||
mark = " exclusive" if spec.get("exclusive") else ""
|
||||
|
||||
if got is None:
|
||||
if args.dry_run:
|
||||
print("create %-20s %s%s" % (name, spec["color"], mark))
|
||||
continue
|
||||
payload = dict(spec, name=name)
|
||||
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
if not new or "id" not in new:
|
||||
_gitea.die("could not create label %r" % name)
|
||||
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
|
||||
continue
|
||||
|
||||
if not drift:
|
||||
print("present %-20s id %s" % (name, got.get("id")))
|
||||
continue
|
||||
|
||||
drifted += 1
|
||||
shown = ", ".join("%s %s -> %s" % d for d in drift)
|
||||
if not args.fix:
|
||||
print("present %-20s id %-5s drift: %s" % (name, got.get("id"), shown))
|
||||
continue
|
||||
if args.dry_run:
|
||||
print("fix %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||
continue
|
||||
# Gitea 1.26 patches only the fields it is given, but the unchanged
|
||||
# name and description ride along anyway: they cost nothing and an
|
||||
# older server that reads an absent field as empty would blank them.
|
||||
patch = {"name": name, "description": got.get("description") or ""}
|
||||
for field, _is, _want in drift:
|
||||
patch[field] = spec[field]
|
||||
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
fixed += 1
|
||||
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||
|
||||
for name, id, hits in similar:
|
||||
_gitea.warn("%r (id %s) resembles %s — left alone; rename it by hand or ignore it"
|
||||
% (name, id, ", ".join(hits)))
|
||||
|
||||
missing = sum(1 for r in rows if r[2] is None)
|
||||
print("%d canonical label(s): %d %s, %d present%s%s"
|
||||
% (len(rows), missing, "to create" if args.dry_run else "created",
|
||||
len(rows) - missing,
|
||||
" (%d drifted, %d fixed)" % (drifted, fixed) if drifted else "",
|
||||
", %d similar" % len(similar) if similar else ""))
|
||||
if drifted and not args.fix:
|
||||
print("drift is shown, not applied — re-run with --fix to patch color/exclusive")
|
||||
if args.dry_run:
|
||||
print("dry-run — nothing was written")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
map.py — md <-> Gitea 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 domain Issue; give it an Issue 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 (issue.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
|
||||
----------------------------------------------------------------------
|
||||
id (slug) body marker `<!-- tea:id … -->`, first line of
|
||||
the tracker-side body; stripped out
|
||||
of the local copy — see below
|
||||
title title verbatim, both ways
|
||||
body body verbatim up, verbatim down except
|
||||
the marker and checkbox state — see
|
||||
with_id_marker / merge_checkbox_state
|
||||
state state open/closed, same vocabulary
|
||||
labels labels[] names both ways; ids only on write
|
||||
assignees assignees[] logins
|
||||
milestone milestone.title resolved to an id on write
|
||||
depends — slugs; #N is translated at the edge
|
||||
— number, html_url lands in extra as gitea:/url:
|
||||
— ref extra as branch:; push fills it from git
|
||||
|
||||
`depends:` is the authoritative graph and is always slugs. The body's
|
||||
`## Depends on` section is human prose and is passed through UNCHANGED in both
|
||||
directions: a pull seeds `depends:` from the `#N` it finds there, and a push
|
||||
never rewrites what the author wrote. Deliberate — a translator that edits
|
||||
prose churns the body on every round trip.
|
||||
|
||||
The ONE thing this module does add to a body is the id marker, and it does so
|
||||
because the slug now has to survive a push: `push.py` deletes the local file,
|
||||
so the tracker has to remember what the issue was called here. See
|
||||
`with_id_marker`.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..", "issue", "scripts")))
|
||||
import issue # noqa: E402
|
||||
|
||||
# How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
|
||||
# an issue IS, which is exactly why it lives here and not in the domain.
|
||||
LABEL_COLORS = {
|
||||
"type/bug": "#ee0701",
|
||||
"type/task": "#0e8a16",
|
||||
"type/refactor": "#1d76db",
|
||||
"type/test": "#fbca04",
|
||||
"type/feature": "#5319e7",
|
||||
"type/draft": "#cccccc",
|
||||
"severity/low": "#c2e0c6",
|
||||
"severity/medium": "#fbca04",
|
||||
"severity/high": "#eb6420",
|
||||
"severity/showstopper": "#ee0701",
|
||||
"severity/critical": "#b60205",
|
||||
}
|
||||
DEFAULT_COLOR = "#ededed"
|
||||
|
||||
# What this bridge writes into the domain's `origin:` field. The domain records
|
||||
# that an issue exists somewhere else; only this module knows where.
|
||||
ORIGIN = "gitea"
|
||||
|
||||
# Metadata key for Gitea's `ref` — the branch an issue is pinned to. A sync
|
||||
# field: its value is a git branch name and means exactly `ref`, so the domain
|
||||
# carries it in `extra` and never reads it.
|
||||
BRANCH_KEY = "branch"
|
||||
|
||||
|
||||
def label_specs(names):
|
||||
"""{name: {color, description, exclusive}} for the transport to create.
|
||||
|
||||
Exclusivity and meaning come from the domain taxonomy; only the color is
|
||||
decided here. `tea labels create` cannot set `exclusive` (as of 0.14.2),
|
||||
which is why these go through the API."""
|
||||
out = {}
|
||||
for name in names:
|
||||
desc = ""
|
||||
if name.startswith("type/"):
|
||||
desc = issue.TYPES.get(name.split("/", 1)[1], "")
|
||||
out[name] = {
|
||||
"color": LABEL_COLORS.get(name, DEFAULT_COLOR),
|
||||
"description": desc,
|
||||
"exclusive": name.startswith(issue.EXCLUSIVE_NS),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def remote_key(repo, number):
|
||||
"""Stable cross-repo handle: owner/repo#42."""
|
||||
return "%s#%d" % (repo, int(number))
|
||||
|
||||
|
||||
def parse_remote_key(key):
|
||||
repo, _, num = (key or "").rpartition("#")
|
||||
return (repo, int(num)) if repo and num.isdigit() else (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the id marker: the slug, kept tracker-side
|
||||
# --------------------------------------------------------------------------
|
||||
# `push.py` deletes the local file once the tracker has confirmed the write, so
|
||||
# the slug — the issue's ONLY identity in the domain — cannot live only on this
|
||||
# machine any more. It rides up in the body as an HTML comment:
|
||||
#
|
||||
# <!-- tea:id wire-sqlc-appclick -->
|
||||
#
|
||||
# Why the body and not `.remote.json`: the map is a local file, and "the local
|
||||
# copy is not the record" is the whole point of deleting it. A marker in the
|
||||
# body survives a rename in the web UI, a lost `.remote.json`, a fresh clone,
|
||||
# and a second machine — none of which the map does. Why an HTML comment: Gitea
|
||||
# renders markdown, so it is invisible to a human reader, and it comes back
|
||||
# verbatim on every API read.
|
||||
#
|
||||
# WHERE: the first line of the tracker-side body, followed by one blank line.
|
||||
# First because it is the one position that does not depend on what sections the
|
||||
# issue happens to have, and because a human who does look at the raw markdown
|
||||
# finds it before the prose rather than buried in it.
|
||||
#
|
||||
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
|
||||
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
|
||||
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
|
||||
# and the slug is already the file's name, so a copy of it in the body would be
|
||||
# duplicated state.
|
||||
#
|
||||
# WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
|
||||
# strip-all-then-prepend-one. `with_id_marker` never appends to what is there,
|
||||
# and `strip_id_marker` removes EVERY marker line, not the first. So a body that
|
||||
# somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on the
|
||||
# next pull and goes back up with exactly one. There is no code path that adds
|
||||
# a marker to a body that has not just been stripped.
|
||||
|
||||
_MARKER_LINE = re.compile(r'^[ \t]*<!--[ \t]*tea:id[ \t]+(\S+)[ \t]*-->[ \t]*$')
|
||||
|
||||
|
||||
def id_marker(id):
|
||||
"""The marker line for a slug. One place formats it, one regex reads it."""
|
||||
return "<!-- tea:id %s -->" % id
|
||||
|
||||
|
||||
def id_in_body(body):
|
||||
"""The slug a tracker-side body claims, or None.
|
||||
|
||||
The FIRST valid marker wins; a second one is ignored here and removed by
|
||||
`strip_id_marker` on the way in. The captured text must be a slug by the
|
||||
domain's own rule — a marker holding anything else is not a slug and is
|
||||
treated as if it were not there, so a mangled comment falls back to the
|
||||
title instead of naming a file after garbage."""
|
||||
for line in (body or "").splitlines():
|
||||
m = _MARKER_LINE.match(line)
|
||||
if m and issue.SLUG_OK.match(m.group(1)):
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def strip_id_marker(body):
|
||||
"""`body` with every marker line removed. Idempotent.
|
||||
|
||||
A body that carries no marker is returned byte for byte — the common case
|
||||
(an issue filed in the web UI) costs nothing and is not reformatted. When a
|
||||
marker is removed from the top, the blank line it was written with goes with
|
||||
it, so the round trip is exact: strip(with_id_marker(b, id)) == b."""
|
||||
text = body or ""
|
||||
if not any(_MARKER_LINE.match(l) for l in text.splitlines()):
|
||||
return text
|
||||
kept = [l for l in text.splitlines() if not _MARKER_LINE.match(l)]
|
||||
return "\n".join(kept).lstrip("\n")
|
||||
|
||||
|
||||
def with_id_marker(body, id):
|
||||
"""`body` with exactly one marker, as its first line.
|
||||
|
||||
Strip-then-prepend, always — that is the guarantee that a body can never end
|
||||
up with two, however many it arrived with."""
|
||||
return "%s\n\n%s" % (id_marker(id), strip_id_marker(body))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gitea -> domain
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def numbers_in_body(body):
|
||||
"""`#N` referenced from the body's dependency sections, as ints. Used only
|
||||
to seed `depends:` on the first pull."""
|
||||
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
|
||||
|
||||
|
||||
def merge_checkbox_state(remote_body, local_body):
|
||||
"""The remote body with every tick the local copy already had put back.
|
||||
|
||||
The one exception to "a pull overwrites the body", and it is deliberately
|
||||
the narrowest one that works. A tick is **monotone** — an item only ever
|
||||
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
|
||||
reconciled: no base version, no drift tracking, no conflict to resolve. The
|
||||
set is a set of item TEXTS, and an item comes out ticked when either side
|
||||
has it ticked. Everything else in the body is still the remote's word.
|
||||
|
||||
Matching is on `Checkbox.text`, which the domain parser has already
|
||||
stripped and rejoined with single spaces, so rewrapping a long item does
|
||||
not cost it its tick. It is otherwise literal: reword an item and it is a
|
||||
different item — the tick stays with the wording it was put on.
|
||||
|
||||
**The same text more than once** is read as the rule says, as a set: one
|
||||
ticked local item ticks every remote item with that text. The alternative —
|
||||
pairing duplicates up by order — is the reading that can still drop a tick
|
||||
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
|
||||
nothing), and dropping a tick is the bug this exists to fix. Two items
|
||||
whose text is identical are the same item to whoever reads them.
|
||||
|
||||
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
|
||||
pull, an empty store — returns the remote body untouched.
|
||||
|
||||
The price, accepted explicitly: UNticking is not monotone, so a box
|
||||
unticked in the web UI comes back on the next pull. Untick locally, push.
|
||||
"""
|
||||
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
|
||||
if not ticked:
|
||||
return remote_body
|
||||
body = remote_body
|
||||
# set_checkbox trades one character for one character, so line numbers read
|
||||
# off `remote_body` stay valid against the partially rewritten `body`.
|
||||
for c in issue.checkboxes(remote_body):
|
||||
if not c.checked and c.text in ticked:
|
||||
body = issue.set_checkbox(body, c.line, True)
|
||||
return body
|
||||
|
||||
|
||||
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
|
||||
local_body=None):
|
||||
"""Build a domain Issue from a Gitea issue payload.
|
||||
|
||||
id_for_number maps a Gitea number to a local slug — dependencies whose
|
||||
target has not been pulled yet are dropped from `depends:` (the body still
|
||||
names them, so nothing is lost) rather than invented.
|
||||
|
||||
`local_body` is the body of the copy already in the store, when there is
|
||||
one. It contributes exactly one thing: its ticked checkboxes survive the
|
||||
overwrite (merge_checkbox_state). Pass None and the remote body is taken
|
||||
whole, which is what a first pull does.
|
||||
|
||||
The id marker is stripped before anything else looks at the body: it is
|
||||
transport bookkeeping, and the caller has already read the slug off it
|
||||
(`pull.id_for`). Everything downstream — checkboxes, `#N` references, what
|
||||
lands on disk — sees the body the author wrote."""
|
||||
body = merge_checkbox_state(
|
||||
strip_id_marker((payload.get("body") or "").strip()), local_body)
|
||||
id_for_number = id_for_number or {}
|
||||
|
||||
numbers = list(numbers_in_body(body))
|
||||
for n in extra_numbers:
|
||||
if n not in numbers:
|
||||
numbers.append(n)
|
||||
depends, unresolved = [], []
|
||||
for n in numbers:
|
||||
slug = id_for_number.get(n)
|
||||
if slug and slug != id and slug not in depends:
|
||||
depends.append(slug)
|
||||
elif not slug:
|
||||
unresolved.append(n)
|
||||
|
||||
extra = {
|
||||
"gitea": remote_key(repo, payload["number"]),
|
||||
"url": payload.get("html_url", ""),
|
||||
"synced": synced or "",
|
||||
}
|
||||
if payload.get("ref"):
|
||||
extra[BRANCH_KEY] = payload["ref"]
|
||||
if payload.get("updated_at"):
|
||||
extra["remote-updated"] = payload["updated_at"]
|
||||
if payload.get("comments"):
|
||||
extra["comments"] = payload["comments"]
|
||||
|
||||
iss = issue.Issue(
|
||||
id=id,
|
||||
title=payload.get("title", ""),
|
||||
body=body,
|
||||
state=payload.get("state") or "open",
|
||||
labels=[l.get("name", "") for l in payload.get("labels") or []],
|
||||
assignees=[a.get("login", "") for a in payload.get("assignees") or []],
|
||||
milestone=(payload.get("milestone") or {}).get("title") or "",
|
||||
depends=depends,
|
||||
origin=ORIGIN,
|
||||
extra=extra)
|
||||
return iss, unresolved
|
||||
|
||||
|
||||
def render_comments(comments):
|
||||
"""Comment thread as flat markdown. Read-only: nothing writes it back."""
|
||||
out = []
|
||||
for c in comments:
|
||||
out.append("## comment %s — %s — %s" % (
|
||||
c.get("id"), (c.get("user") or {}).get("login", ""),
|
||||
(c.get("created_at") or "")[:10]))
|
||||
out.append("")
|
||||
out.append((c.get("body") or "(empty)").strip())
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# domain -> Gitea
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
|
||||
"""Request body for POST /issues or PATCH /issues/{n}.
|
||||
|
||||
The prose is sent verbatim — see the module docstring on why slugs in
|
||||
`## Depends on` are not rewritten to `#N`. The one addition is the id
|
||||
marker, prepended (never appended) so the tracker remembers the slug after
|
||||
push has deleted the local file. `from_api` takes it straight back off, so
|
||||
the body still round-trips byte for byte."""
|
||||
payload = {"title": iss.title,
|
||||
"body": with_id_marker(iss.body.strip(), iss.id)}
|
||||
if label_ids is not None:
|
||||
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
|
||||
if iss.assignees:
|
||||
payload["assignees"] = list(iss.assignees)
|
||||
if milestone_id is not None:
|
||||
payload["milestone"] = milestone_id
|
||||
if include_state:
|
||||
payload["state"] = iss.state
|
||||
# An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
||||
# clear whatever is set on the Gitea side, so the key is left out instead.
|
||||
branch = (iss.extra.get(BRANCH_KEY) or "").strip()
|
||||
if branch:
|
||||
payload["ref"] = branch
|
||||
return payload
|
||||
|
||||
|
||||
def apply_remote(iss, payload, repo, synced):
|
||||
"""Stamp the sync-owned fields onto an issue after a successful write.
|
||||
Mutates and returns it; `origin` is the one domain field this touches."""
|
||||
iss.origin = ORIGIN
|
||||
iss.extra["gitea"] = remote_key(repo, payload["number"])
|
||||
iss.extra["url"] = payload.get("html_url", "")
|
||||
iss.extra["synced"] = synced
|
||||
if payload.get("updated_at"):
|
||||
iss.extra["remote-updated"] = payload["updated_at"]
|
||||
return iss
|
||||
|
||||
|
||||
def number_of(iss):
|
||||
"""Gitea number for an already-synced issue, or None."""
|
||||
_repo, n = parse_remote_key(iss.extra.get("gitea", ""))
|
||||
return n
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pull.py — Gitea issues -> the local store.
|
||||
|
||||
Writes flat markdown the domain layer owns and prints a compact index; the raw
|
||||
API payload never reaches the conversation.
|
||||
|
||||
**This is how you get a pushed issue back.** `push.py` deletes the local file
|
||||
once Gitea has confirmed it, so pulling is not a refresh of a copy you kept —
|
||||
it is how the copy comes to exist. It lands under the SAME slug it had before,
|
||||
even after a rename in the web UI and even on a machine that has never seen the
|
||||
issue: the slug travels in the body as `<!-- tea:id … -->`, and
|
||||
tmp/issues/.remote.json indexes it by number. See `id_for` for the order those
|
||||
are consulted in. The marker itself is stripped out of what is written to disk.
|
||||
|
||||
Two ways to name what to pull:
|
||||
|
||||
pull.py 42 [17 …] by key: 42 | #42 | owner/repo#42 | URL
|
||||
pull.py --milestone 6 by filter: whole milestone in ONE request
|
||||
pull.py --label type/bug --state all
|
||||
pull.py -q sqlc --limit 20
|
||||
|
||||
Filter mode costs one request per 50 issues — the list payload already carries
|
||||
the bodies. Gitea silently ignores an unresolvable `milestones=` filter and
|
||||
returns the whole backlog, so the milestone is resolved up front and every
|
||||
issue is re-checked locally. Projects are NOT filterable: the projects API is
|
||||
not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI.
|
||||
|
||||
A closed issue is not a unit of work, so filter mode enumerates it but leaves
|
||||
it out of the store: `--state all` still shows the whole picture, and only
|
||||
`--state closed` writes one. An issue already on disk is refreshed either way,
|
||||
so the local copy learns it was closed instead of staying open forever, and the
|
||||
count of the ones left out goes to stderr. Key mode is exempt: an address is not
|
||||
a bulk read, and `pull.py 1` fetches a closed issue as it always did.
|
||||
|
||||
**`--limit` is on the write, not on the selection.** It counts the issues this
|
||||
run puts in the store — written, or left in place by `--cached` — and never the
|
||||
closed ones it enumerated and threw away. `--limit 20` over a milestone whose
|
||||
first 30 issues are closed still writes 20, if 20 open ones are there to write:
|
||||
pages keep coming until the budget is full. Two boundaries keep that honest:
|
||||
|
||||
- Pages stop the moment the budget is full. Never one page more.
|
||||
- A filtered read may scan at most `_gitea.PAGE_SLACK` times the pages the limit
|
||||
would need if nothing were dropped. A filter that matches almost only closed
|
||||
issues therefore ends in a warning and a short answer, not in a walk of the
|
||||
whole tracker. Narrow the filter, or raise `--limit`, which raises the budget
|
||||
with it.
|
||||
- Dependencies are outside the count: a blocker is followed because a stored
|
||||
issue named it, not because the filter selected it. `--limit 20` can
|
||||
therefore leave more than 20 files behind — the budget counts the selection's
|
||||
writes, and the graph is not part of the selection.
|
||||
|
||||
`remote.py` is the deliberate exception, and it is not the same flag twice: it
|
||||
writes nothing at all, so there is no write to bound and its `--limit` means
|
||||
what it says — how many lines to print.
|
||||
|
||||
Comments ride along by default, in both modes and for every issue written:
|
||||
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
|
||||
nothing when there is nothing to fetch — the payload already carries the
|
||||
comment count, so an issue with none makes no request, and a file left over
|
||||
from an earlier pull is deleted. An absent file therefore means "no comments",
|
||||
never "not asked for". The thread is pull-only: editing it changes nothing in
|
||||
Gitea (post with comment.py).
|
||||
|
||||
**Dependencies come with every pull.** A pull answers with the whole unit of
|
||||
work — the issue and what blocks it — so `depends:` is filled from Gitea's
|
||||
native dependency graph and every blocker is pulled too, recursively, down to
|
||||
`--depth` (default 3). That graph is the only source there is: `map.from_api`
|
||||
writes slugs into the `## Depends on` prose and never `#N`, so an edge cannot be
|
||||
recovered from the body. `--no-deps` turns off both halves — no `depends:`, no
|
||||
recursion, and no request spent on either. `--deps` is still accepted and now
|
||||
does nothing; it names what already happens.
|
||||
|
||||
What it costs, stated rather than hidden:
|
||||
|
||||
- **One request per issue that lands in the store** — `GET …/issues/{n}/dependencies`,
|
||||
fetched once and used twice, since the same links both fill `depends:` and
|
||||
tell the walk where to go next. A closed issue that filter mode drops costs
|
||||
nothing: nothing was stored, so there is no unit of work to complete.
|
||||
- **One request per blocker the selection did not already carry** — a `GET` for
|
||||
the issue itself, then its own links, and so on until `--depth`.
|
||||
- So `--milestone X` over 50 open issues is one list request + 50 link requests
|
||||
+ one pair for every blocker outside the milestone, where it used to be one
|
||||
request flat. `--no-deps` is the way back to one.
|
||||
|
||||
**In filter mode a blocker the filter did not select still lands in the store,
|
||||
and that is deliberate.** `--milestone X` can leave an issue from milestone Y on
|
||||
disk and `--label` an unlabelled one: a blocker is followed because a stored
|
||||
issue names it, not because it matched. The one blocker that does not land is a
|
||||
closed one — closed is not a unit of work, filter mode drops it the way it drops
|
||||
any other closed issue, and the `depends:` edge to it goes with it, so nothing
|
||||
points at a file that is not there. Key mode has no such rule and stores it.
|
||||
|
||||
Other flags:
|
||||
--no-deps do not fill depends:, do not follow blockers
|
||||
--deps accepted, does nothing: it is the default now
|
||||
--depth N how deep to follow blockers (default 3)
|
||||
--cached skip issues already on disk (body AND comments)
|
||||
--repo owner/repo default: auto-detect from the CWD git remote
|
||||
|
||||
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
|
||||
have not pushed are lost — with exactly one exception, checkbox state. A `[x]`
|
||||
on either side wins for any item whose text matches, because a tick is monotone
|
||||
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
|
||||
has the rule and its price). `--cached` skips an issue before any of that: it is
|
||||
not read and not merged — it still costs its one link request, because a cached
|
||||
issue's blockers can be missing from disk even when it is not (`--cached
|
||||
--no-deps` is the free one). Draw the graph afterwards with the domain's own
|
||||
issue_tree.py — it needs no network.
|
||||
|
||||
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_index # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def id_for(payload, store_ids, remote_map, repo, root):
|
||||
"""The slug this remote issue belongs under. Three sources, in order.
|
||||
|
||||
1. **`.remote.json`, keyed by number.** The local ledger, and the only one
|
||||
that knows about a file sitting on disk right now, so it wins. A
|
||||
retitled issue keeps the slug it was first pulled under.
|
||||
2. **The `<!-- tea:id … -->` marker in the body** (`gmap.id_in_body`). What
|
||||
makes push -> delete -> pull a round trip rather than a rename: the
|
||||
ledger can be lost (a fresh clone, another machine, a deleted
|
||||
`.remote.json`) and the tracker still remembers what this issue is called
|
||||
here — even after the title was changed in the web UI.
|
||||
3. **The title, slugified.** Issues filed in the web UI have no marker and
|
||||
have never had a local name; this is where they get one.
|
||||
|
||||
A marker is only taken at its word when the slug is free. If a file of that
|
||||
name is already in the store, or the ledger has it under another number, the
|
||||
marker is a collision and not an identity — the name is uniquified
|
||||
(`marked-2`) rather than allowed to overwrite somebody else's issue."""
|
||||
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
|
||||
if got:
|
||||
return got
|
||||
marked = gmap.id_in_body(payload.get("body") or "")
|
||||
if marked and marked not in store_ids and marked not in set(remote_map.values()):
|
||||
return marked
|
||||
return issue.unique_id(root, marked or issue.slugify(payload.get("title", "")),
|
||||
taken=store_ids)
|
||||
|
||||
|
||||
def lands_in_store(payload, drop_closed, store_ids, remote_map, repo, root):
|
||||
"""Would this payload leave a file in the store? The `--limit` predicate.
|
||||
|
||||
It has to be the same test the walk below applies, or the budget is spent on
|
||||
issues that never land — which is the bug this exists to prevent. So: a
|
||||
closed issue counts only when the store already has it (it is refreshed, and
|
||||
that is a write); anything else counts, including one `--cached` will skip,
|
||||
because a skipped issue is still an issue the store holds when the run ends.
|
||||
|
||||
Cheap in the common case: only a closed payload costs an `id_for`, and that
|
||||
is a lookup plus, at worst, a stat."""
|
||||
if not (drop_closed and payload.get("state") == "closed"):
|
||||
return True
|
||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||
return os.path.isfile(issue.path_of(root, id))
|
||||
|
||||
|
||||
def comments_path(root, id):
|
||||
"""Where an issue's comment thread lives — beside it, under the same slug.
|
||||
Named in `_gitea` because push.py has to delete the same file."""
|
||||
return _gitea.comments_path(root, id)
|
||||
|
||||
|
||||
def sync_comments(login, base, root, id, number, count):
|
||||
"""Bring <id>.comments.md in line with the server; return it, or None when
|
||||
the issue has no thread.
|
||||
|
||||
`count` is the payload's own comment count, so an issue with none costs no
|
||||
request. A file from an earlier pull is removed when the thread is empty:
|
||||
the absence of the file is the answer, not a gap in what was asked for."""
|
||||
path = comments_path(root, id)
|
||||
comments = _gitea.get_comments(login, base, number) if count else []
|
||||
if comments:
|
||||
with open(path, "w") as f:
|
||||
f.write(gmap.render_comments(comments))
|
||||
return path
|
||||
if os.path.isfile(path):
|
||||
os.remove(path) # stale thread from an earlier pull
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Pull Gitea issues into the local store")
|
||||
ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL")
|
||||
ap.add_argument("--milestone", help="pull a whole milestone (id or title)")
|
||||
ap.add_argument("--label", action="append", default=[],
|
||||
help="filter by label; repeat for AND")
|
||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
|
||||
help="filter mode only (default: open)")
|
||||
ap.add_argument("--limit", type=int, default=100,
|
||||
help="filter mode: how many issues to STORE, not to enumerate"
|
||||
" (default: 100)")
|
||||
# Dependencies are the default: a pull answers with the unit of work, not
|
||||
# one row of it. `--deps` stays accepted so the calls and command tables
|
||||
# written against the old default keep working — it now sets what is
|
||||
# already set.
|
||||
ap.add_argument("--no-deps", dest="deps", action="store_false",
|
||||
help="do not fill depends: and do not follow blockers")
|
||||
ap.add_argument("--deps", dest="deps", action="store_true",
|
||||
help="accepted, does nothing: dependencies are followed by default")
|
||||
ap.set_defaults(deps=True)
|
||||
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
|
||||
ap.add_argument("--cached", action="store_true",
|
||||
help="skip issues already on disk instead of refetching")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
filtered = bool(args.milestone or args.label or args.query)
|
||||
if args.keys and filtered:
|
||||
_gitea.die("pass issue keys OR filters, not both")
|
||||
if not args.keys and not filtered:
|
||||
_gitea.die("nothing to pull: pass issue keys, or --milestone / --label / -q")
|
||||
|
||||
root = args.out
|
||||
# A first pull into a fresh checkout has to create the store; it says so,
|
||||
# and the path is absolute, so it cannot be a stray cwd.
|
||||
if issue.create_store(root):
|
||||
sys.stderr.write("created store %s\n" % os.path.abspath(root))
|
||||
|
||||
login = _gitea.require_login()
|
||||
|
||||
# ---- which repo ------------------------------------------------------
|
||||
repo_arg = args.repo
|
||||
if not repo_arg and args.keys:
|
||||
repos = {_gitea.parse_key(k)[1] for k in args.keys} - {None}
|
||||
if len(repos) > 1:
|
||||
_gitea.die("all keys must belong to one repo, got: %s" % ", ".join(sorted(repos)))
|
||||
repo_arg = repos.pop() if repos else None
|
||||
base = _gitea.repo_base(repo_arg)
|
||||
repo = _gitea.repo_slug(login, repo_arg)
|
||||
|
||||
issues = issue.load_all(root)
|
||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
||||
store_ids = set(issues)
|
||||
number_of_id = {gmap.parse_remote_key(k)[1]: v for k, v in remote_map.items()
|
||||
if gmap.parse_remote_key(k)[0] == repo}
|
||||
|
||||
# A closed issue is not a unit of work: filter mode enumerates it but keeps
|
||||
# it out of the store unless the operator named the state. A key is an
|
||||
# address, not a bulk read, so key mode is exempt.
|
||||
drop_closed = filtered and args.state != "closed"
|
||||
|
||||
written, skipped, dropped, pending = [], [], [], []
|
||||
|
||||
# ---- seeds -----------------------------------------------------------
|
||||
if filtered:
|
||||
# The limit bounds the write, so the transport is told what a write is
|
||||
# and counts those; the closed ones it enumerated on the way come back
|
||||
# in the list anyway, to be reported and dropped below.
|
||||
payloads, ms_title = _gitea.list_issues(
|
||||
login, base, state=args.state, labels=args.label, query=args.query,
|
||||
milestone=args.milestone, limit=args.limit,
|
||||
keep=lambda p: lands_in_store(p, drop_closed, store_ids, remote_map,
|
||||
repo, root))
|
||||
if not payloads:
|
||||
_gitea.die("no issues match that filter")
|
||||
what = []
|
||||
if args.milestone:
|
||||
what.append("milestone %s" % ms_title)
|
||||
what += ["label %s" % l for l in args.label]
|
||||
if args.query:
|
||||
what.append("q=%r" % args.query)
|
||||
sys.stderr.write("%d issue(s) match %s (%s)\n"
|
||||
% (len(payloads), " + ".join(what), args.state))
|
||||
queue = [(p, 0) for p in payloads]
|
||||
seen_numbers = {p["number"] for p in payloads}
|
||||
else:
|
||||
numbers = [_gitea.parse_key(k)[0] for k in args.keys]
|
||||
queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers]
|
||||
seen_numbers = set(numbers)
|
||||
|
||||
# ---- walk ------------------------------------------------------------
|
||||
while queue:
|
||||
payload, depth = queue.pop(0)
|
||||
number = payload["number"]
|
||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||
stored = os.path.isfile(issue.path_of(root, id))
|
||||
|
||||
# Closed and not already ours: nothing is written and nothing is asked
|
||||
# of the server for it — not its comments, not its links, and its own
|
||||
# blockers are not followed. The slug stays unclaimed too, so no other
|
||||
# issue ends up pointing `depends:` at a missing file.
|
||||
if drop_closed and payload.get("state") == "closed" and not stored:
|
||||
dropped.append(number)
|
||||
continue # not stored: no unit of work here, so no links are fetched
|
||||
|
||||
store_ids.add(id)
|
||||
number_of_id[number] = id
|
||||
|
||||
# The native links, fetched ONCE for the two things they are for:
|
||||
# filling this issue's `depends:` and telling the walk where to go next.
|
||||
# One request per issue that lands in the store, and only one — the cost
|
||||
# the docstring quotes is this line.
|
||||
deps = _gitea.native_deps(login, base, number) if args.deps else []
|
||||
|
||||
if args.cached and stored:
|
||||
skipped.append(id) # body and thread unread; only the links cost
|
||||
else:
|
||||
# The copy already on disk, as it was when this run started. It
|
||||
# contributes its ticked checkboxes and nothing else; None when
|
||||
# the store has never seen this issue.
|
||||
prev = issues.get(id)
|
||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||
id_for_number=number_of_id,
|
||||
extra_numbers=deps,
|
||||
synced=_gitea.now_iso(),
|
||||
local_body=prev.body if prev else None)
|
||||
issue.save(root, iss)
|
||||
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
written.append(id)
|
||||
pending.append((id, unresolved))
|
||||
|
||||
if args.deps and depth < args.depth:
|
||||
child_numbers = gmap.numbers_in_body(payload.get("body") or "") + deps
|
||||
for n in child_numbers:
|
||||
if n in seen_numbers:
|
||||
continue
|
||||
seen_numbers.add(n)
|
||||
queue.append((_gitea.get_issue(login, base, n), depth + 1))
|
||||
|
||||
# Nothing is dropped in silence — say how many closed ones stayed out.
|
||||
if dropped:
|
||||
sys.stderr.write("%d closed issue(s) enumerated, not stored"
|
||||
" (--state closed to pull them)\n" % len(dropped))
|
||||
|
||||
# ---- second pass: dependencies that were not yet known on first write --
|
||||
for id, unresolved in pending:
|
||||
newly = [number_of_id[n] for n in unresolved
|
||||
if n in number_of_id and number_of_id[n] != id]
|
||||
if not newly:
|
||||
continue
|
||||
iss = issue.load(root, id)
|
||||
for slug in newly:
|
||||
if slug not in iss.depends:
|
||||
iss.depends.append(slug)
|
||||
issue.save(root, iss)
|
||||
|
||||
_gitea.save_map(root, remote_map)
|
||||
index_path, _ = issue_index.build(root)
|
||||
|
||||
# Compact output — the only thing that lands in the model's context. The
|
||||
# thread rides on the issue's own line; no file means no comments.
|
||||
graph = False
|
||||
for id in sorted(set(written) | set(skipped)):
|
||||
iss = issue.load(root, id)
|
||||
graph = graph or bool(iss.depends)
|
||||
note = " (cached)" if id in skipped else ""
|
||||
cpath = comments_path(root, id)
|
||||
if os.path.isfile(cpath):
|
||||
note += " +%s comments: %s" % (iss.extra.get("comments") or "?", cpath)
|
||||
print("%s [%s] %s — %s %s%s" % (
|
||||
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
||||
issue.path_of(root, id), note))
|
||||
print("index: %s" % index_path)
|
||||
# Now that dependencies are the default, the hint is worth printing when
|
||||
# there is something to draw, not on every run that could have drawn it.
|
||||
if graph:
|
||||
print("graph: run issue_tree.py (offline) to draw it")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
push.py — local store -> Gitea, and the local copy goes away.
|
||||
|
||||
**A successful push deletes `tmp/issues/<id>.md` and `<id>.comments.md`.** Once
|
||||
the tracker has the issue, the tracker IS the issue: what is left in the store
|
||||
is only what has not left this machine. Get it back with `pull.py <n>` — it
|
||||
comes back under the same slug, because the slug travelled up in the body as
|
||||
`<!-- tea:id … -->` (map.with_id_marker) and is also recorded in
|
||||
`.remote.json`. That is the reversal of "pushing is additive, the file is never
|
||||
deleted"; it is deliberate, and AGENTS.md and references/format.md say so too.
|
||||
|
||||
ONE RULE, NO EXCEPTION: `--update` deletes as well. A PATCH is a push, and an
|
||||
issue that has just been sent is no more local than one that was just created.
|
||||
Two rules would put back exactly the question this removes — "is my copy the
|
||||
fresh one?".
|
||||
|
||||
The deletion is the LAST thing that happens to an issue, and only after:
|
||||
|
||||
1. the api call returned (it did not raise, and `tea` exited 0), and
|
||||
2. the answer is a dict carrying a plausible `number`, and on `--update`
|
||||
the very number that was PATCHed (`confirmed_number`), and
|
||||
3. `.remote.json` has been written with number -> slug.
|
||||
|
||||
Network down, non-2xx, a body that does not confirm the write, a mismatched
|
||||
number: the file stays and the run stops. Nothing here removes a file it has not
|
||||
just watched Gitea accept, and nothing removes a file for an issue it did not
|
||||
send — `origin: local` work that has never been pushed is never touched.
|
||||
|
||||
push.py every local-only issue, dependencies first
|
||||
push.py wire-sqlc-appclick one issue
|
||||
push.py --update <id …> PATCH issues that are already in Gitea
|
||||
push.py --dry-run validate only, no network, nothing deleted
|
||||
|
||||
Before anything is sent, each issue is validated against the canonical format
|
||||
by the domain layer (exactly one type/*, English title with no type prefix,
|
||||
`## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts
|
||||
anyway; say why when you use it.
|
||||
|
||||
Dependencies are pushed in topological order so a parent is created after the
|
||||
issues it depends on. A dependency that is still local-only is reported, not
|
||||
silently dropped — the body's `## Depends on` prose is sent verbatim either
|
||||
way, so nothing is lost, but the tracker shows no edge for it.
|
||||
|
||||
The graph goes up with them. Once an issue has its number, every `depends:`
|
||||
entry that also has one becomes a **native Gitea link** — the same
|
||||
`/dependencies` that every `pull.py` reads back, so the tracker shows the
|
||||
blocking panel and refuses to close a blocked issue first. Topological order
|
||||
means the blocker already has its number by then; no second pass is needed.
|
||||
`--update` links whatever appeared in `depends:` since the last push. A link
|
||||
the tracker already has is skipped, not re-POSTed. A dependency that stayed
|
||||
local has no number and becomes no link — only the warning above.
|
||||
|
||||
REMOVING a link is OUT OF SCOPE. Push only ever adds: a dependency deleted
|
||||
from `depends:` leaves its Gitea link standing, and nothing here will notice.
|
||||
Unlink it in the web UI, or by hand with
|
||||
`tea api -X DELETE --login "$GITEA_LOGIN" repos/OWNER/REPO/issues/N/dependencies`.
|
||||
|
||||
The `## Depends on` prose itself is never touched — slugs stay slugs and are
|
||||
not rewritten to `#N`, so the body survives a pull -> push round trip byte for
|
||||
byte. The link lives in Gitea's own graph, not in the text.
|
||||
|
||||
Missing labels are created with the canonical color and, for type/* and
|
||||
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
||||
|
||||
`branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is
|
||||
filled with the current git branch and goes up with the issue; one that is
|
||||
already set is sent as written and never overwritten. Detached HEAD, or no repo
|
||||
at all: no `ref` is sent and a warning says so. It is not written back to the
|
||||
file any more — there is no file to write it back to; it comes down with the
|
||||
next pull.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def select(issues, ids, update):
|
||||
"""Which issues to send, and refuse the ambiguous combinations."""
|
||||
if ids:
|
||||
missing = [i for i in ids if i not in issues]
|
||||
if missing:
|
||||
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
||||
chosen = list(ids)
|
||||
else:
|
||||
chosen = sorted(i for i in issues
|
||||
if update or not issues[i].extra.get("gitea"))
|
||||
if not chosen:
|
||||
_gitea.die("nothing to push: every issue in the store is already in Gitea "
|
||||
"(use --update to PATCH them, or issue_new.py to make one)")
|
||||
if not update:
|
||||
already = [i for i in chosen if issues[i].extra.get("gitea")]
|
||||
if already:
|
||||
_gitea.die("already in Gitea: %s — pass --update to PATCH them"
|
||||
% ", ".join(already))
|
||||
return chosen
|
||||
|
||||
|
||||
def ledger_keys(remote_map, repo=None):
|
||||
"""slug -> remote key, the reverse of `.remote.json`.
|
||||
|
||||
Where a dependency's number comes from once push has deleted its file. The
|
||||
forward map is keyed by number because that is what a pull has in hand; a
|
||||
push has a slug, so it needs the other direction. Same-repo entries win if a
|
||||
slug somehow appears under two keys."""
|
||||
out = {}
|
||||
for key, slug in sorted(remote_map.items()):
|
||||
if slug not in out or gmap.parse_remote_key(key)[0] == repo:
|
||||
out[slug] = key
|
||||
return out
|
||||
|
||||
|
||||
def dep_state(iss, issues, pushing, key_of_id=None):
|
||||
"""What each `depends:` entry is, as far as linking is concerned.
|
||||
|
||||
Yields (slug, remote_key, in_run) per dependency this run can say anything
|
||||
about:
|
||||
|
||||
remote_key where the dependency lives in Gitea, or None while it is
|
||||
local-only
|
||||
in_run this push is about to give it one
|
||||
|
||||
A dependency's key is read from its `gitea:` field when the file is still
|
||||
on disk, and from the ledger (`key_of_id`) when it is not — which, since
|
||||
push deletes what it sends, is the normal state of an already-published
|
||||
blocker. Without that fallback the graph would quietly lose an edge every
|
||||
time a blocker was pushed before its dependent: the file is gone, the field
|
||||
goes with it, and the link is never made.
|
||||
|
||||
A slug that is neither in the store nor in the ledger is dropped; it names
|
||||
nothing this machine has ever seen, and validate() has already warned.
|
||||
|
||||
In the real run remote_key is all that matters — topological order means an
|
||||
in-run blocker has already been stamped by the time its dependent is sent.
|
||||
`--dry-run` has no numbers to stamp, so it leans on in_run to say which
|
||||
links are coming and which cannot exist at all."""
|
||||
key_of_id = key_of_id or {}
|
||||
out = []
|
||||
for d in iss.depends:
|
||||
dep = issues.get(d)
|
||||
key = (dep.extra.get("gitea") if dep is not None else None) or key_of_id.get(d)
|
||||
if dep is None and not key:
|
||||
continue
|
||||
out.append((d, key or None, d in pushing))
|
||||
return out
|
||||
|
||||
|
||||
def confirmed_number(got, sent_number=None):
|
||||
"""The number Gitea confirmed for a write, or None — the deletion gate.
|
||||
|
||||
Every local file this script removes is removed because this function
|
||||
returned an int, so it is written to be boring and to say no by default.
|
||||
An answer counts only when it is a dict carrying a positive integer
|
||||
`number`, and, when `sent_number` is given (a PATCH, where we already know
|
||||
which issue we addressed), the same number we sent.
|
||||
|
||||
`bool` is rejected explicitly: `True` is an `int` in Python and `number:
|
||||
true` is not a confirmation of anything.
|
||||
|
||||
What this does NOT have to catch, because it never gets here: a non-2xx
|
||||
answer or a `tea` that failed to run at all — `_gitea.api` exits on both,
|
||||
and an exception in the transport propagates. The file survives all three
|
||||
by never reaching the delete."""
|
||||
if not isinstance(got, dict):
|
||||
return None
|
||||
n = got.get("number")
|
||||
if isinstance(n, bool) or not isinstance(n, int) or n <= 0:
|
||||
return None
|
||||
if sent_number is not None and n != sent_number:
|
||||
return None
|
||||
return n
|
||||
|
||||
|
||||
def drop_local(root, id):
|
||||
"""Delete the local copy of an issue and its thread; return what went.
|
||||
|
||||
Deliberately dumb: it takes an id, not a decision. Whether an issue may be
|
||||
dropped is decided by the caller, before this is reached, so the dangerous
|
||||
half of the operation has no branches in it at all. There is exactly one
|
||||
call site.
|
||||
|
||||
A missing file is not an error — an issue with no comments has no thread."""
|
||||
gone = []
|
||||
for p in (issue.path_of(root, id), _gitea.comments_path(root, id)):
|
||||
if os.path.isfile(p):
|
||||
os.remove(p)
|
||||
gone.append(p)
|
||||
return gone
|
||||
|
||||
|
||||
def git_branch():
|
||||
"""The branch HEAD is on, or None. The only git call these scripts make —
|
||||
read, never write. A detached HEAD prints `HEAD` and outside a repo git
|
||||
exits non-zero; both mean "no branch to name", which is not an error."""
|
||||
try:
|
||||
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True)
|
||||
except OSError:
|
||||
return None
|
||||
name = r.stdout.strip()
|
||||
if r.returncode != 0 or not name or name == "HEAD":
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Push local issues to Gitea")
|
||||
ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)")
|
||||
ap.add_argument("--update", action="store_true",
|
||||
help="PATCH issues that already carry a gitea: field")
|
||||
ap.add_argument("--dry-run", action="store_true", help="validate only, no network")
|
||||
ap.add_argument("--force", action="store_true", help="push despite format violations")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = args.out
|
||||
problem = issue.store_error(root)
|
||||
if problem:
|
||||
_gitea.die("%s — create an issue with issue_new.py first" % problem)
|
||||
issues = issue.load_all(root)
|
||||
|
||||
chosen = select(issues, args.ids, args.update)
|
||||
|
||||
# ---- validate (domain layer, no network) -----------------------------
|
||||
known = set(issues)
|
||||
blocked = False
|
||||
for id in chosen:
|
||||
err, warn = issue.validate(issues[id], known_ids=known)
|
||||
for w in warn:
|
||||
_gitea.warn("%s: %s" % (id, w))
|
||||
for e in err:
|
||||
sys.stderr.write("%s: %s\n" % (id, e))
|
||||
if err:
|
||||
blocked = True
|
||||
if blocked and not args.force:
|
||||
_gitea.die("format violations (see above); --force overrides")
|
||||
|
||||
# ---- dependencies first ----------------------------------------------
|
||||
edges = {i: [d for d in issues[i].depends if d in issues] for i in chosen}
|
||||
order = [i for i in issue.topo_order(chosen, edges) if i in set(chosen)]
|
||||
for c in issue.find_cycles(edges):
|
||||
_gitea.warn("dependency cycle: %s" % " -> ".join(c))
|
||||
|
||||
# ---- branch: -> Gitea `ref` ------------------------------------------
|
||||
# Only an empty field is filled: a branch written by hand is the author's
|
||||
# decision and push does not argue with it. Nothing to read (detached HEAD,
|
||||
# no repo) is not an error — the issue goes up without a `ref`. The value is
|
||||
# set on the in-memory issue only; the file it came from is about to be
|
||||
# deleted, and the branch comes back with the next pull.
|
||||
blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)]
|
||||
branch = git_branch() if blank else None
|
||||
if branch:
|
||||
for id in blank:
|
||||
issues[id].extra[gmap.BRANCH_KEY] = branch
|
||||
elif blank:
|
||||
_gitea.warn("no current git branch (detached HEAD, or outside a git repo) "
|
||||
"— no `ref` on: %s" % ", ".join(blank))
|
||||
|
||||
pushing = set(order)
|
||||
|
||||
if args.dry_run:
|
||||
links = 0
|
||||
# The ledger costs no request, so a dry run resolves an already-pushed
|
||||
# blocker the same way the real run does.
|
||||
key_of_id = ledger_keys(_gitea.load_map(root), args.repo)
|
||||
for id in order:
|
||||
iss = issues[id]
|
||||
print("ok %s [type/%s] %s (%s)"
|
||||
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
|
||||
# Not one request is made here: everything below is read off the
|
||||
# store. `#?` is a number this run has not handed out yet.
|
||||
for slug, key, in_run in dep_state(iss, issues, pushing, key_of_id):
|
||||
if key:
|
||||
print(" link -> %s (%s)" % (key, slug))
|
||||
links += 1
|
||||
elif in_run:
|
||||
print(" link -> #? (%s, created by this run)" % slug)
|
||||
links += 1
|
||||
else:
|
||||
print(" no link: %s is local-only" % slug)
|
||||
print("%d issue(s) would be %s, %d dependency link(s) would be created"
|
||||
% (len(order), "updated" if args.update else "created", links))
|
||||
return
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(args.repo)
|
||||
repo = _gitea.repo_slug(login, args.repo)
|
||||
|
||||
wanted = sorted({l for id in order for l in issues[id].labels})
|
||||
label_ids = _gitea.ensure_labels(login, base, gmap.label_specs(wanted), root) \
|
||||
if wanted else {}
|
||||
|
||||
milestone_ids = {}
|
||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
||||
key_of_id = ledger_keys(remote_map, repo)
|
||||
|
||||
for id in order:
|
||||
iss = issues[id]
|
||||
|
||||
# Local-only means "this machine has never sent it": no `gitea:` on the
|
||||
# file AND no entry in the ledger. A blocker whose file push already
|
||||
# dropped is in the ledger and is not one of these.
|
||||
unsynced = [d for d in iss.depends
|
||||
if d in issues and not issues[d].extra.get("gitea")
|
||||
and d not in key_of_id and d not in pushing]
|
||||
if unsynced:
|
||||
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
||||
% (id, ", ".join(unsynced)))
|
||||
|
||||
ms_id = None
|
||||
if iss.milestone:
|
||||
if iss.milestone not in milestone_ids:
|
||||
milestone_ids[iss.milestone] = _gitea.resolve_milestone_id(
|
||||
login, base, iss.milestone)
|
||||
ms_id = milestone_ids[iss.milestone]
|
||||
if ms_id is None:
|
||||
_gitea.warn("%s: milestone %r does not exist in %s — not set"
|
||||
% (id, iss.milestone, repo))
|
||||
|
||||
sent_number = gmap.number_of(iss)
|
||||
if sent_number:
|
||||
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
||||
got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH",
|
||||
payload, payload_name="issue-%s" % id)
|
||||
verb = "updated"
|
||||
else:
|
||||
payload = gmap.to_payload(iss, label_ids, ms_id)
|
||||
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
|
||||
payload_name="issue-%s" % id)
|
||||
verb = "created"
|
||||
# The gate. Below this line the local file is going to be deleted, so
|
||||
# anything short of a confirmed write has to stop the run here.
|
||||
number = confirmed_number(got, sent_number)
|
||||
if number is None:
|
||||
_gitea.die("%s: %s failed — the tracker's answer does not confirm the "
|
||||
"write (%.200r). %s is untouched."
|
||||
% (id, verb, got, issue.path_of(root, id)))
|
||||
|
||||
# The number is confirmed, so the ledger learns it now — before the
|
||||
# label fix-up below, which can still fail, and well before the file is
|
||||
# removed. `.remote.json` is what a later `pull.py N` uses to land on
|
||||
# this slug again; an interrupted run must cost a re-pull, not a slug.
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
key_of_id[id] = gmap.remote_key(repo, number)
|
||||
_gitea.save_map(root, remote_map)
|
||||
|
||||
# Gitea occasionally drops labels on create — re-apply rather than
|
||||
# trust the echo.
|
||||
applied = {l.get("name", "") for l in got.get("labels") or []}
|
||||
missing = [l for l in iss.labels if l in label_ids and l not in applied]
|
||||
if missing:
|
||||
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
|
||||
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
|
||||
payload_name="labels-%s" % id)
|
||||
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
|
||||
|
||||
# The in-memory issue is stamped even though its file is going: the rest
|
||||
# of this loop reads `gitea:` off it to link dependencies, and a later
|
||||
# issue in topological order asks the same of this one.
|
||||
gmap.apply_remote(iss, got, repo, _gitea.now_iso())
|
||||
|
||||
# Where the issue lives now. The number and the URL lead because this
|
||||
# is the receipt: in a moment the local path is gone and this is the
|
||||
# only address the issue has.
|
||||
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
|
||||
|
||||
# ---- the graph, as Gitea's own links ------------------------------
|
||||
# Blockers came first in topological order, so each one that is going
|
||||
# to have a number has one already — stamped on the in-memory issue
|
||||
# above, or read out of the ledger for one whose file an earlier push
|
||||
# already dropped. The GET is the idempotence check: it costs one
|
||||
# request per issue that has dependencies at all, and it is what makes
|
||||
# a repeat push a no-op.
|
||||
wanted_links = [(slug, gmap.parse_remote_key(key))
|
||||
for slug, key, _ in dep_state(iss, issues, pushing, key_of_id)
|
||||
if key]
|
||||
if wanted_links:
|
||||
have = _gitea.native_dep_pairs(login, base, number)
|
||||
for slug, (drepo, dnum) in wanted_links:
|
||||
if not dnum or (drepo, dnum) in have:
|
||||
continue
|
||||
if _gitea.add_dependency(login, base, number, drepo, dnum):
|
||||
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
|
||||
else:
|
||||
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
|
||||
"hand, or `pull.py %d` and push it again"
|
||||
% (id, number, drepo, dnum, slug, number))
|
||||
|
||||
# ---- and now the local copy goes ----------------------------------
|
||||
# The last thing that happens to this issue, after the write, the
|
||||
# ledger, and the links. A failure above is a warning and lands here
|
||||
# anyway: the issue IS in Gitea, so keeping a stale file beside it
|
||||
# would put back exactly the two-copies question this removes.
|
||||
for p in drop_local(root, id):
|
||||
print(" dropped %s" % p)
|
||||
print(" pull.py %d to work on it again" % number)
|
||||
|
||||
_gitea.save_map(root, remote_map)
|
||||
path, n = issue_index.build(root)
|
||||
print("index: %s — %d issue(s)" % (path, n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,6 +16,11 @@ Usage:
|
||||
remote.py [--state open|closed|all] [--label L]… [-q TEXT]
|
||||
[--milestone M] [--limit N] [--repo owner/repo]
|
||||
|
||||
`--limit` here caps the LISTING: N lines out, closed ones among them. That is
|
||||
not what the same flag means to `pull.py`, and the difference is not an
|
||||
oversight — pull.py bounds what it writes, and this command writes nothing, so
|
||||
there is nothing else for a limit to bound. Enumeration is the whole job.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
@@ -39,7 +44,8 @@ def main():
|
||||
ap.add_argument("--milestone", help="milestone id or title")
|
||||
ap.add_argument("--limit", type=int, default=30)
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
login = _gitea.require_login()
|
||||
@@ -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,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Checkbox state survives a pull; everything else in the body does not.
|
||||
|
||||
Two levels, on purpose. `map.merge_checkbox_state` is pure, so most of the rule
|
||||
is pinned down with plain strings and no store anywhere. The pull tests then
|
||||
prove the rule is actually wired into the write path, with the transport
|
||||
stubbed at the one seam `test_push_dependencies.py` uses — `_gitea.api`, the
|
||||
single function that shells out to `tea`. Nothing here touches a network, and
|
||||
no test may ever be made to.
|
||||
|
||||
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
import pull # noqa: E402
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
|
||||
|
||||
def body(*criteria, **kw):
|
||||
"""A body in the canonical shape, with the given `## Acceptance criteria`."""
|
||||
summary = kw.get("summary", "Прозаическое описание.")
|
||||
return ("## Summary\n%s\n\n## Spec\nnone\n\n## Acceptance criteria\n%s\n"
|
||||
% (summary, "\n".join(criteria))).strip()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the rule itself — pure, no store, no tracker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class MergeCheckboxStateTest(unittest.TestCase):
|
||||
"""`[x]` wins from whichever side has it, for a matching item text."""
|
||||
|
||||
def test_a_local_tick_survives_the_overwrite(self):
|
||||
got = gmap.merge_checkbox_state(body("- [ ] первое", "- [ ] второе"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_a_remote_tick_is_kept(self):
|
||||
got = gmap.merge_checkbox_state(body("- [x] первое", "- [ ] второе"),
|
||||
body("- [ ] первое", "- [ ] второе"))
|
||||
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_both_sides_ticked_is_still_ticked(self):
|
||||
one = body("- [x] первое")
|
||||
self.assertEqual(gmap.merge_checkbox_state(one, one), one)
|
||||
|
||||
def test_the_union_is_taken_item_by_item(self):
|
||||
got = gmap.merge_checkbox_state(
|
||||
body("- [x] первое", "- [ ] второе", "- [ ] третье"),
|
||||
body("- [ ] первое", "- [x] второе", "- [ ] третье"))
|
||||
self.assertEqual(got, body("- [x] первое", "- [x] второе", "- [ ] третье"))
|
||||
|
||||
def test_an_item_the_local_copy_does_not_have_comes_from_the_server(self):
|
||||
"""Including its state — both states, in both directions."""
|
||||
got = gmap.merge_checkbox_state(
|
||||
body("- [x] новое сверху", "- [ ] новое снизу"),
|
||||
body("- [x] что-то совсем другое"))
|
||||
self.assertEqual(got, body("- [x] новое сверху", "- [ ] новое снизу"))
|
||||
|
||||
def test_prose_is_not_merged(self):
|
||||
got = gmap.merge_checkbox_state(
|
||||
body("- [ ] пункт", summary="Новый текст с сервера."),
|
||||
body("- [x] пункт", summary="Старый локальный текст."))
|
||||
self.assertIn("Новый текст с сервера.", got)
|
||||
self.assertNotIn("Старый локальный текст.", got)
|
||||
self.assertIn("- [x] пункт", got)
|
||||
|
||||
def test_a_heading_the_local_copy_added_is_gone(self):
|
||||
remote = body("- [x] пункт")
|
||||
got = gmap.merge_checkbox_state(remote, remote + "\n\n## Notes\nмои заметки\n")
|
||||
self.assertEqual(got, remote)
|
||||
|
||||
def test_no_local_copy_returns_the_server_body_untouched(self):
|
||||
remote = body("- [ ] пункт")
|
||||
for local in (None, "", " \n"):
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, local), remote,
|
||||
"local_body=%r rewrote the body" % local)
|
||||
|
||||
def test_nothing_ticked_locally_returns_the_same_object(self):
|
||||
remote = body("- [ ] пункт")
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [ ] пункт")), remote)
|
||||
|
||||
def test_no_matching_item_returns_the_same_object(self):
|
||||
remote = body("- [ ] пункт")
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] другой")), remote)
|
||||
|
||||
def test_a_body_with_no_checkboxes_at_all(self):
|
||||
remote = "## Summary\nодна проза\n"
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, "- [x] пункт"), remote)
|
||||
self.assertEqual(gmap.merge_checkbox_state("- [ ] пункт", remote), "- [ ] пункт")
|
||||
|
||||
def test_exactly_one_character_changes(self):
|
||||
"""Ticking a box must not produce a diff wider than the state."""
|
||||
remote = body("- [ ] пункт", "- [ ] второй")
|
||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт", "- [ ] второй"))
|
||||
diff = [i for i, (a, b) in enumerate(zip(remote, got)) if a != b]
|
||||
self.assertEqual(len(remote), len(got))
|
||||
self.assertEqual(len(diff), 1)
|
||||
self.assertEqual((remote[diff[0]], got[diff[0]]), (" ", "x"))
|
||||
|
||||
def test_a_rewrapped_item_keeps_its_tick(self):
|
||||
"""`Checkbox.text` joins continuation lines with one space, which is
|
||||
the whole reason matching survives a reflow."""
|
||||
remote = body("- [ ] длинный пункт, который сервер\n"
|
||||
" перенёс на две строки")
|
||||
got = gmap.merge_checkbox_state(
|
||||
remote, body("- [x] длинный пункт, который сервер перенёс на две строки"))
|
||||
self.assertIn("- [x] длинный пункт", got)
|
||||
|
||||
def test_a_reworded_item_does_not_keep_its_tick(self):
|
||||
"""Different text is a different item. The tick stays with the wording
|
||||
it was put on — this is a match, not a guess."""
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт про sqlc"),
|
||||
body("- [x] пункт про SQLC"))
|
||||
self.assertEqual(got, body("- [ ] пункт про sqlc"))
|
||||
|
||||
def test_the_marker_style_does_not_have_to_match(self):
|
||||
"""`-`, `*` and `1.` are all checkbox markers to the domain parser, so
|
||||
the item is the same item however the two sides chose to render it."""
|
||||
got = gmap.merge_checkbox_state(body("1. [ ] пункт"), body("* [x] пункт"))
|
||||
self.assertEqual(got, body("1. [x] пункт"))
|
||||
|
||||
def test_a_moved_item_keeps_its_tick(self):
|
||||
"""Matching is on text alone; the section is not part of the key. An
|
||||
item promoted out of `## Acceptance criteria` is the same item."""
|
||||
remote = "## Issues\n- [ ] пункт\n"
|
||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
||||
self.assertEqual(got, "## Issues\n- [x] пункт\n")
|
||||
|
||||
def test_duplicate_text_is_read_as_a_set(self):
|
||||
"""The documented reading: one ticked local item ticks every remote
|
||||
line with that text. Pairing duplicates by order is the alternative,
|
||||
and it is the one that can still drop a tick."""
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт", "- [ ] пункт"),
|
||||
body("- [ ] пункт", "- [x] пункт"))
|
||||
self.assertEqual(got, body("- [x] пункт", "- [x] пункт"))
|
||||
|
||||
def test_duplicate_text_never_loses_the_second_tick(self):
|
||||
"""Two local lines, one remote: order-pairing would drop this tick."""
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"),
|
||||
body("- [ ] пункт", "- [x] пункт"))
|
||||
self.assertEqual(got, body("- [x] пункт"))
|
||||
|
||||
def test_an_example_inside_a_code_fence_is_not_ticked(self):
|
||||
"""The domain parser skips fences whole, and so does the merge: a
|
||||
`- [ ]` in a fence is markup being shown, not a box anyone may tick."""
|
||||
remote = "## Spec\n```md\n- [ ] пункт\n```\n\n## Acceptance criteria\n- [ ] пункт\n"
|
||||
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
||||
self.assertEqual(got, remote.replace("## Acceptance criteria\n- [ ] пункт",
|
||||
"## Acceptance criteria\n- [x] пункт"))
|
||||
self.assertIn("```md\n- [ ] пункт\n```", got)
|
||||
|
||||
def test_an_existing_capital_X_is_left_alone(self):
|
||||
remote = body("- [X] пункт")
|
||||
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] пункт")), remote)
|
||||
|
||||
def test_a_capital_X_locally_still_counts_as_ticked(self):
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [X] пункт"))
|
||||
self.assertEqual(got, body("- [x] пункт"))
|
||||
|
||||
def test_unticking_in_the_web_does_not_survive(self):
|
||||
"""The accepted price, pinned so nobody 'fixes' it by accident:
|
||||
unticking is not monotone, so a box unticked upstream comes back.
|
||||
Untick locally and push."""
|
||||
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
||||
self.assertEqual(got, body("- [x] пункт"))
|
||||
|
||||
def test_parsing_is_the_domain_layer_s(self):
|
||||
"""The acceptance criterion, asserted rather than eyeballed: the rule
|
||||
calls into skills/issue and defines no checkbox syntax of its own."""
|
||||
with mock.patch.object(issue, "checkboxes", wraps=issue.checkboxes) as cb, \
|
||||
mock.patch.object(issue, "set_checkbox", wraps=issue.set_checkbox) as sc:
|
||||
gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
||||
self.assertTrue(cb.called)
|
||||
self.assertTrue(sc.called)
|
||||
|
||||
def test_no_checkbox_markup_is_spelled_out_in_the_sync_layer(self):
|
||||
"""The same criterion from the other side: the bracket markup itself
|
||||
appears nowhere under skills/sync/scripts. Knowing what `[ ]` looks
|
||||
like is the domain's job, and there is only one copy of it."""
|
||||
sync = os.path.join(_ROOT, "skills", "sync", "scripts")
|
||||
for name in sorted(f for f in os.listdir(sync) if f.endswith(".py")):
|
||||
with open(os.path.join(sync, name)) as f:
|
||||
code = f.read().split('"""')[0::2] # docstrings dropped
|
||||
for chunk in code:
|
||||
for markup in ("[ xX]", "[xX]", "- [ ]", "- [x]"):
|
||||
self.assertNotIn(markup, chunk,
|
||||
"%s spells out %r" % (name, markup))
|
||||
|
||||
|
||||
class FromApiTest(unittest.TestCase):
|
||||
"""The seam between the rule and the translation."""
|
||||
|
||||
PAYLOAD = {"number": 42, "title": "T", "html_url": "u",
|
||||
"body": body("- [ ] пункт")}
|
||||
|
||||
def test_local_body_is_optional_and_defaults_to_no_merge(self):
|
||||
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO)
|
||||
self.assertEqual(iss.body, body("- [ ] пункт"))
|
||||
|
||||
def test_local_body_contributes_its_ticks(self):
|
||||
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO,
|
||||
local_body=body("- [x] пункт"))
|
||||
self.assertEqual(iss.body, body("- [x] пункт"))
|
||||
|
||||
def test_an_empty_remote_body_does_not_crash(self):
|
||||
iss, _ = gmap.from_api({"number": 42, "title": "T", "body": None},
|
||||
"an-issue", REPO, local_body=body("- [x] пункт"))
|
||||
self.assertEqual(iss.body, "")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# pull.py — the rule wired into the write path
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FakeGitea(object):
|
||||
"""A `tea api` that answers issues from memory and remembers the calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.issues = {}
|
||||
|
||||
def add(self, number, title, text, **kw):
|
||||
p = {"number": number, "title": title, "body": text, "state": "open",
|
||||
"comments": 0, "html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
||||
"updated_at": "2026-08-10T00:00:00Z"}
|
||||
p.update(kw)
|
||||
self.issues[number] = p
|
||||
return p
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None, **kw):
|
||||
self.calls.append((method, endpoint))
|
||||
path, _, query = endpoint.partition("?")
|
||||
params = dict(urllib.parse.parse_qsl(query))
|
||||
|
||||
# Every pull asks for an issue's native links now (dependencies are the
|
||||
# default). Nothing here has any; the answer just has to exist.
|
||||
if path.endswith("/dependencies"):
|
||||
return []
|
||||
|
||||
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
|
||||
if m and method == "GET":
|
||||
return self.issues.get(int(m.group(1)))
|
||||
if path == "%s/issues" % BASE and method == "GET":
|
||||
if int(params.get("page", 1)) > 1:
|
||||
return []
|
||||
return [self.issues[n] for n in sorted(self.issues)]
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class PullTestCase(unittest.TestCase):
|
||||
"""A temp store and a fake transport."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
||||
self.fake = FakeGitea()
|
||||
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)
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
|
||||
def write_local(self, id, text, number=42):
|
||||
issue.save(self.root, issue.Issue(
|
||||
id=id, title="An issue", body=text, labels=["type/task"],
|
||||
origin="gitea", extra={"gitea": "%s#%d" % (REPO, number),
|
||||
"url": "https://git.example/x", "synced": "old"}))
|
||||
|
||||
def run_pull(self, *argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(err):
|
||||
pull.main()
|
||||
return out.getvalue(), err.getvalue()
|
||||
|
||||
def stored_body(self, id):
|
||||
return issue.load(self.root, id).body
|
||||
|
||||
def raw(self, id):
|
||||
"""The file on disk, byte for byte — metadata included."""
|
||||
with open(issue.path_of(self.root, id)) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class PullMergesTicksTest(PullTestCase):
|
||||
|
||||
def test_a_tick_made_locally_survives_the_pull(self):
|
||||
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_a_tick_made_in_the_web_lands_locally(self):
|
||||
self.write_local("an-issue", body("- [ ] первое", "- [ ] второе"))
|
||||
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_the_rest_of_the_body_is_still_overwritten(self):
|
||||
self.write_local("an-issue", body("- [x] первое",
|
||||
summary="Локальная правка прозы."))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] новое с сервера",
|
||||
summary="Серверная проза."))
|
||||
self.run_pull("42")
|
||||
got = self.stored_body("an-issue")
|
||||
self.assertIn("Серверная проза.", got)
|
||||
self.assertNotIn("Локальная правка прозы.", got)
|
||||
self.assertIn("- [x] первое", got)
|
||||
self.assertIn("- [ ] новое с сервера", got)
|
||||
|
||||
def test_an_item_the_server_added_arrives_ticked_if_the_server_ticked_it(self):
|
||||
self.write_local("an-issue", body("- [ ] первое"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [x] новое с сервера"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [ ] первое", "- [x] новое с сервера"))
|
||||
|
||||
def test_filter_mode_merges_too(self):
|
||||
self.write_local("an-issue", body("- [x] первое"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое"))
|
||||
self.run_pull("--label", "type/task")
|
||||
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
||||
|
||||
def test_a_retitled_issue_keeps_its_slug_and_its_ticks(self):
|
||||
"""The merge hangs off the local id, which is resolved from the number
|
||||
— a title change must not orphan the ticks."""
|
||||
self.write_local("an-issue", body("- [x] первое"))
|
||||
self.fake.add(42, "Completely different title", body("- [ ] первое"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
||||
self.assertEqual(issue.load(self.root, "an-issue").title,
|
||||
"Completely different title")
|
||||
|
||||
|
||||
class PullIntoAnEmptyStoreTest(PullTestCase):
|
||||
|
||||
def test_no_local_file_writes_the_server_body_unchanged(self):
|
||||
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
def test_a_store_that_does_not_exist_yet_is_created_and_not_merged(self):
|
||||
shutil.rmtree(self.root)
|
||||
self.fake.add(42, "An issue", body("- [ ] первое"))
|
||||
_out, err = self.run_pull("42")
|
||||
self.assertIn("created store", err)
|
||||
self.assertEqual(self.stored_body("an-issue"), body("- [ ] первое"))
|
||||
|
||||
|
||||
class CachedIsUnchangedTest(PullTestCase):
|
||||
|
||||
def test_a_skipped_issue_is_neither_read_nor_merged(self):
|
||||
self.write_local("an-issue", body("- [x] первое"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||
before = self.raw("an-issue")
|
||||
|
||||
with mock.patch.object(gmap, "merge_checkbox_state") as merge:
|
||||
out, _ = self.run_pull("42", "--cached")
|
||||
|
||||
merge.assert_not_called()
|
||||
self.assertIn("(cached)", out)
|
||||
self.assertEqual(self.raw("an-issue"), before)
|
||||
|
||||
def test_without_cached_the_same_issue_is_merged(self):
|
||||
self.write_local("an-issue", body("- [x] первое"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.stored_body("an-issue"),
|
||||
body("- [x] первое", "- [ ] второе"))
|
||||
|
||||
|
||||
class RoundTripTest(PullTestCase):
|
||||
"""Pull twice with no change in between: the second is a no-op."""
|
||||
|
||||
def test_a_repeat_pull_does_not_churn_the_file(self):
|
||||
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
||||
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||
self.run_pull("42")
|
||||
first = self.raw("an-issue")
|
||||
self.run_pull("42")
|
||||
self.assertEqual(self.raw("an-issue"), first)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Checkbox parsing, ticking, and the INDEX progress column.
|
||||
|
||||
Plain stdlib unittest — the scripts under test are stdlib-only by the layering
|
||||
rule, and their tests have no business dragging in a dependency the code they
|
||||
cover is forbidden to have. `skills/*/scripts/` are directories of scripts, not
|
||||
packages, so they go on sys.path the same way the scripts do it to each other.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Nothing here touches tmp/, the network, or the real store: every case builds
|
||||
its own store in a TemporaryDirectory.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "skills", "issue", "scripts"))
|
||||
|
||||
import issue # noqa: E402
|
||||
import issue_ac # noqa: E402
|
||||
import issue_check # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
|
||||
# Boxes in two different sections, a wrapped item, a fenced example, and a
|
||||
# plain list item that is not a checkbox at all. Line numbers are 1-based:
|
||||
# the items sit on lines 8, 9, 12, 14 and 15.
|
||||
BODY = """## Summary
|
||||
Что-то про задачу.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Issues
|
||||
- [x] wire-sqlc-appclick — первая часть
|
||||
- [ ] add-pool-cfg — вторая часть
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] в `issue.py` есть функция разбора чекбоксов тела:
|
||||
возвращает пункты с номером строки, состоянием и текстом
|
||||
- [X] чекбоксы ищутся по всему телу
|
||||
- [ ] пример в блоке кода не считается пунктом:
|
||||
|
||||
```markdown
|
||||
- [ ] это разметка из шаблона, а не галочка
|
||||
- [x] и эта тоже
|
||||
```
|
||||
|
||||
## Constraints
|
||||
- не входит в объём: доставка тела в трекер
|
||||
"""
|
||||
|
||||
NO_BOXES = """## Summary
|
||||
Тело без единой галочки.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Notes
|
||||
- обычный пункт списка
|
||||
- ещё один
|
||||
"""
|
||||
|
||||
# Metadata deliberately out of canonical order and missing optional keys, one
|
||||
# item with trailing whitespace: a round-trip through Issue.to_text() would
|
||||
# rewrite all of that, so this fixture catches a ticking path that re-renders
|
||||
# the file instead of patching one character of it.
|
||||
MESSY = """---
|
||||
origin: local
|
||||
labels: [type/task]
|
||||
id: messy-issue
|
||||
state: open
|
||||
---
|
||||
# Messy but valid
|
||||
|
||||
## Summary
|
||||
Тело, которое нельзя перерисовывать.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] первый пункт
|
||||
- [ ] второй пункт
|
||||
- [ ] третий пункт
|
||||
"""
|
||||
|
||||
TASK_BODY = """## Summary
|
||||
Что нужно сделать.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Motivation
|
||||
Зачем это нужно.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] ничего ещё не сделано
|
||||
- [ ] и это тоже не сделано
|
||||
"""
|
||||
|
||||
|
||||
def sole_difference(before, after):
|
||||
"""The single character position at which the two strings differ.
|
||||
|
||||
Raises AssertionError when they differ in length or in more than one
|
||||
place — the whole claim of `set_checkbox` is that this never happens."""
|
||||
assert len(before) == len(after), (
|
||||
"length changed: %d -> %d" % (len(before), len(after)))
|
||||
diff = [i for i, (a, b) in enumerate(zip(before, after)) if a != b]
|
||||
assert len(diff) == 1, "expected 1 differing character, got %d" % len(diff)
|
||||
return diff[0]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def store(**files):
|
||||
"""A throwaway issue store: {id: file text}."""
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
for id, text in files.items():
|
||||
with open(os.path.join(root, "%s.md" % id), "w", newline="") as f:
|
||||
f.write(text)
|
||||
yield root
|
||||
|
||||
|
||||
def run(fn, *argv):
|
||||
"""Call a script entry point, returning (exit code or None, stdout)."""
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = fn(list(argv))
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
class TestParse(unittest.TestCase):
|
||||
|
||||
def test_finds_every_box_in_every_section(self):
|
||||
items = issue.checkboxes(BODY)
|
||||
self.assertEqual([c.index for c in items], [1, 2, 3, 4, 5])
|
||||
self.assertEqual([c.line for c in items], [8, 9, 12, 14, 15])
|
||||
self.assertEqual([c.checked for c in items],
|
||||
[True, False, False, True, False])
|
||||
self.assertEqual([c.section for c in items],
|
||||
["## Issues", "## Issues"] + ["## Acceptance criteria"] * 3)
|
||||
|
||||
def test_boxes_outside_acceptance_criteria_are_items_too(self):
|
||||
# A type/feature keeps its children under `## Issues`; binding the
|
||||
# parser to one heading would lose them.
|
||||
under_issues = [c for c in issue.checkboxes(BODY) if c.section == "## Issues"]
|
||||
self.assertEqual(len(under_issues), 2)
|
||||
self.assertTrue(under_issues[0].text.startswith("wire-sqlc-appclick"))
|
||||
|
||||
def test_continuation_line_is_part_of_the_item(self):
|
||||
item = issue.checkboxes(BODY)[2]
|
||||
self.assertEqual((item.line, item.end_line), (12, 13))
|
||||
self.assertEqual(
|
||||
item.text,
|
||||
"в `issue.py` есть функция разбора чекбоксов тела: "
|
||||
"возвращает пункты с номером строки, состоянием и текстом")
|
||||
|
||||
def test_fenced_example_is_not_an_item(self):
|
||||
texts = [c.text for c in issue.checkboxes(BODY)]
|
||||
self.assertNotIn("это разметка из шаблона, а не галочка", texts)
|
||||
self.assertEqual(len(texts), 5)
|
||||
|
||||
def test_plain_list_item_is_not_a_checkbox(self):
|
||||
self.assertNotIn("не входит в объём: доставка тела в трекер",
|
||||
[c.text for c in issue.checkboxes(BODY)])
|
||||
|
||||
def test_markers_and_nesting(self):
|
||||
text = ("* [ ] star\n"
|
||||
"+ [x] plus\n"
|
||||
"1. [ ] ordered\n"
|
||||
"2) [x] ordered too\n"
|
||||
" - [ ] nested\n"
|
||||
"- [x]no space, not an item\n")
|
||||
items = issue.checkboxes(text)
|
||||
self.assertEqual([c.text for c in items],
|
||||
["star", "plus", "ordered", "ordered too", "nested"])
|
||||
self.assertEqual([c.checked for c in items],
|
||||
[False, True, False, True, False])
|
||||
|
||||
def test_empty_text(self):
|
||||
self.assertEqual(issue.checkboxes(""), [])
|
||||
self.assertEqual(issue.checkboxes(None), [])
|
||||
|
||||
def test_line_numbers_are_relative_to_the_text_given(self):
|
||||
# Same body, prefixed with a metadata block: the offsets move with it,
|
||||
# which is what lets issue_ac.py work on a whole file.
|
||||
head = "---\nid: x\nstate: open\n---\n# Title\n\n"
|
||||
shift = head.count("\n")
|
||||
self.assertEqual([c.line for c in issue.checkboxes(head + BODY)],
|
||||
[c.line + shift for c in issue.checkboxes(BODY)])
|
||||
|
||||
def test_progress(self):
|
||||
self.assertEqual(issue.checkbox_progress(BODY), (2, 5))
|
||||
self.assertEqual(issue.checkbox_progress(NO_BOXES), (0, 0))
|
||||
|
||||
|
||||
class TestToggle(unittest.TestCase):
|
||||
|
||||
def test_ticking_changes_exactly_one_character(self):
|
||||
item = issue.checkboxes(BODY)[1] # line 9, unchecked
|
||||
after = issue.set_checkbox(BODY, item, True)
|
||||
at = sole_difference(BODY, after)
|
||||
self.assertEqual(BODY[at], " ")
|
||||
self.assertEqual(after[at], "x")
|
||||
self.assertEqual(issue.checkbox_progress(after), (3, 5))
|
||||
|
||||
def test_unticking_changes_exactly_one_character(self):
|
||||
item = issue.checkboxes(BODY)[0] # line 8, checked
|
||||
after = issue.set_checkbox(BODY, item, False)
|
||||
at = sole_difference(BODY, after)
|
||||
self.assertEqual((BODY[at], after[at]), ("x", " "))
|
||||
|
||||
def test_every_item_toggles_in_isolation(self):
|
||||
for item in issue.checkboxes(BODY):
|
||||
after = issue.set_checkbox(BODY, item, not item.checked)
|
||||
at = sole_difference(BODY, after)
|
||||
self.assertEqual(after.splitlines()[item.line - 1].count("["), 1)
|
||||
self.assertLess(at, len(BODY))
|
||||
|
||||
def test_no_op_when_already_in_that_state(self):
|
||||
items = issue.checkboxes(BODY)
|
||||
self.assertIs(issue.set_checkbox(BODY, items[0], True), BODY)
|
||||
self.assertIs(issue.set_checkbox(BODY, items[1], False), BODY)
|
||||
|
||||
def test_capital_x_is_left_alone(self):
|
||||
item = issue.checkboxes(BODY)[3] # `- [X]`
|
||||
self.assertEqual(issue.set_checkbox(BODY, item, True), BODY)
|
||||
|
||||
def test_accepts_a_line_number(self):
|
||||
after = issue.set_checkbox(BODY, 9, True)
|
||||
self.assertEqual(after, issue.set_checkbox(BODY, issue.checkboxes(BODY)[1], True))
|
||||
|
||||
def test_refuses_a_line_that_is_not_a_checkbox(self):
|
||||
with self.assertRaises(ValueError):
|
||||
issue.set_checkbox(BODY, 1, True)
|
||||
with self.assertRaises(ValueError):
|
||||
issue.set_checkbox(BODY, 9999, True)
|
||||
|
||||
|
||||
class TestScript(unittest.TestCase):
|
||||
|
||||
def test_lists_items_numbered_with_state(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
rc, out = run(issue_ac.main, "messy-issue", "--out", root)
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("messy-issue — 0/3", out)
|
||||
self.assertIn("## Acceptance criteria", out)
|
||||
self.assertIn(" 1 [ ] первый пункт", out)
|
||||
self.assertIn(" 3 [ ] третий пункт", out)
|
||||
|
||||
def test_check_by_number(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
rc, out = run(issue_ac.main, "messy-issue", "--check", "2", "--out", root)
|
||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||
after = f.read()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("checked", out)
|
||||
self.assertIn("1/3", out)
|
||||
self.assertEqual(issue.checkbox_progress(after), (1, 3))
|
||||
|
||||
def test_check_by_substring(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
run(issue_ac.main, "messy-issue", "--check", "ТРЕТИЙ", "--out", root)
|
||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||
after = f.read()
|
||||
self.assertTrue(issue.checkboxes(after)[2].checked)
|
||||
self.assertEqual(issue.checkbox_progress(after), (1, 3))
|
||||
|
||||
def test_uncheck(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
|
||||
rc, out = run(issue_ac.main, "messy-issue", "--uncheck", "1", "--out", root)
|
||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||
after = f.read()
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("unchecked", out)
|
||||
self.assertEqual(after, MESSY)
|
||||
|
||||
def test_toggling_through_the_script_changes_one_character_of_the_file(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
path = os.path.join(root, "messy-issue.md")
|
||||
with open(path) as f:
|
||||
before = f.read()
|
||||
run(issue_ac.main, "messy-issue", "--check", "второй", "--out", root)
|
||||
with open(path) as f:
|
||||
after = f.read()
|
||||
at = sole_difference(before, after)
|
||||
self.assertEqual((before[at], after[at]), (" ", "x"))
|
||||
# The metadata block was neither reordered nor completed, and the
|
||||
# trailing whitespace on the third item survived.
|
||||
self.assertTrue(after.startswith("---\norigin: local\n"))
|
||||
self.assertIn("- [ ] третий пункт \n", after)
|
||||
|
||||
def test_crlf_line_endings_survive(self):
|
||||
crlf = MESSY.replace("\n", "\r\n")
|
||||
with store(**{"messy-issue": crlf}) as root:
|
||||
path = os.path.join(root, "messy-issue.md")
|
||||
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
|
||||
with open(path, newline="") as f:
|
||||
after = f.read()
|
||||
at = sole_difference(crlf, after)
|
||||
self.assertEqual((crlf[at], after[at]), (" ", "x"))
|
||||
self.assertEqual(after.count("\r\n"), crlf.count("\r\n"))
|
||||
|
||||
def test_ambiguous_substring_is_an_error_listing_the_matches(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "messy-issue", "--check", "пункт", "--out", root)
|
||||
with open(os.path.join(root, "messy-issue.md")) as f:
|
||||
self.assertEqual(f.read(), MESSY) # nothing was picked
|
||||
msg = str(cm.exception)
|
||||
self.assertIn("matches 3 items", msg)
|
||||
for want in ("1 [ ] первый пункт", "2 [ ] второй пункт", "3 [ ] третий пункт"):
|
||||
self.assertIn(want, msg)
|
||||
|
||||
def test_substring_that_matches_nothing(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "messy-issue", "--check", "нетакого", "--out", root)
|
||||
self.assertIn("nothing matches", str(cm.exception))
|
||||
|
||||
def test_number_out_of_range(self):
|
||||
with store(**{"messy-issue": MESSY}) as root:
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "messy-issue", "--check", "9", "--out", root)
|
||||
self.assertIn("no item 9 — the issue has 3", str(cm.exception))
|
||||
|
||||
def test_issue_without_checkboxes(self):
|
||||
with store(**{"plain": "---\nid: plain\n---\n# Plain\n\n" + NO_BOXES}) as root:
|
||||
rc, out = run(issue_ac.main, "plain", "--out", root)
|
||||
self.assertEqual((rc, out.strip()), (0, "plain — no checkboxes"))
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "plain", "--check", "1", "--out", root)
|
||||
self.assertIn("has no checkboxes", str(cm.exception))
|
||||
|
||||
def test_unknown_id(self):
|
||||
with store() as root:
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
run(issue_ac.main, "nope", "--out", root)
|
||||
self.assertIn("no issue 'nope'", str(cm.exception))
|
||||
|
||||
|
||||
class TestIndexProgress(unittest.TestCase):
|
||||
|
||||
def files(self):
|
||||
boxed = ("---\nid: boxed\nstate: open\nlabels: [type/task]\n"
|
||||
"origin: local\n---\n# Boxed\n\n" + BODY)
|
||||
plain = ("---\nid: plain\nstate: open\nlabels: [type/task]\n"
|
||||
"origin: local\n---\n# Plain\n\n" + NO_BOXES)
|
||||
return {"boxed": boxed, "plain": plain}
|
||||
|
||||
def index(self, root):
|
||||
issue_index.build(root)
|
||||
with open(os.path.join(root, "INDEX.md")) as f:
|
||||
return f.read()
|
||||
|
||||
def row(self, text, id):
|
||||
for line in text.splitlines():
|
||||
if line.startswith("| [%s]" % id):
|
||||
return [c.strip() for c in line.split("|")]
|
||||
self.fail("no row for %r in INDEX.md" % id)
|
||||
|
||||
def test_column_exists_and_counts_the_body(self):
|
||||
with store(**self.files()) as root:
|
||||
text = self.index(root)
|
||||
self.assertIn("| id | state | progress | type |", text)
|
||||
self.assertEqual(self.row(text, "boxed")[3], "2/5")
|
||||
|
||||
def test_blank_for_an_issue_without_checkboxes(self):
|
||||
with store(**self.files()) as root:
|
||||
text = self.index(root)
|
||||
self.assertEqual(self.row(text, "plain")[3], "")
|
||||
|
||||
def test_recomputed_on_the_fly_not_stored(self):
|
||||
with store(**self.files()) as root:
|
||||
self.assertEqual(self.row(self.index(root), "boxed")[3], "2/5")
|
||||
run(issue_ac.main, "boxed", "--check", "add-pool-cfg", "--out", root)
|
||||
self.assertEqual(self.row(self.index(root), "boxed")[3], "3/5")
|
||||
# No metadata field anywhere holds it.
|
||||
with open(os.path.join(root, "boxed.md")) as f:
|
||||
head = f.read().split("---")[1]
|
||||
self.assertNotIn("3/5", head)
|
||||
self.assertNotIn("progress", head)
|
||||
|
||||
|
||||
class TestCheckIgnoresUntickedBoxes(unittest.TestCase):
|
||||
"""An unticked box is work not done yet, not a malformed issue."""
|
||||
|
||||
def test_validate_reports_nothing(self):
|
||||
iss = issue.Issue(id="unticked-issue", title="Do the thing",
|
||||
labels=["type/task"], body=TASK_BODY)
|
||||
err, warn = issue.validate(iss, known_ids={"unticked-issue"})
|
||||
self.assertEqual(err, [])
|
||||
self.assertEqual(warn, [])
|
||||
|
||||
def test_issue_check_exits_clean(self):
|
||||
text = ("---\nid: unticked-issue\nstate: open\nlabels: [type/task]\n"
|
||||
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n"
|
||||
"---\n# Do the thing\n\n" + TASK_BODY)
|
||||
argv = sys.argv
|
||||
with store(**{"unticked-issue": text}) as root:
|
||||
sys.argv = ["issue_check.py", "--out", root]
|
||||
try:
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = issue_check.main()
|
||||
finally:
|
||||
sys.argv = argv
|
||||
out = buf.getvalue()
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertIn("ok unticked-issue", out)
|
||||
self.assertNotIn("ERROR", out)
|
||||
self.assertNotIn("warn ", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,641 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
close.py — the state changes in Gitea, and the local file follows it or nothing
|
||||
happens at all.
|
||||
|
||||
Two halves, and the second is the one that matters:
|
||||
|
||||
1. **It closes.** A slug, a number, several of either in one run, and
|
||||
`--reopen` going the other way. What goes out is a PATCH carrying `state`
|
||||
and nothing else; what comes back is written into `state:` on the local
|
||||
file, and the index is rebuilt so the store's own table agrees.
|
||||
|
||||
2. **It changes nothing local unless the tracker confirmed it.** A `tea` that
|
||||
exited non-zero, an answer with no number, an answer for another issue, an
|
||||
answer that still says `open`, an `origin: local` issue, a `--dry-run`: in
|
||||
every one of those the file on disk is byte for byte what it was. A bug here
|
||||
makes the store lie about the tracker, so each path is asserted on its own.
|
||||
|
||||
The transport is stubbed at `_gitea.api`, as `test_drop_after_push.py` does,
|
||||
with the same deliberate exception: the non-2xx test stubs `_gitea.subprocess`
|
||||
and lets the real `_gitea.api` run, so "tea exited 1" is proved end to end.
|
||||
|
||||
Nothing here touches a network, and nothing here touches the developer's store:
|
||||
every test builds its own in a `tempfile.TemporaryDirectory()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import close # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
# Captured before any test patches it — the non-2xx test needs the real thing.
|
||||
REAL_API = _gitea.api
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [x] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from memory, for state writes only.
|
||||
|
||||
It keeps a `state` per number and flips it on a PATCH, which is the whole
|
||||
contract close.py has with the far side."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.states = {} # number -> "open" / "closed"
|
||||
self.raise_on_write = None # an exception instance to raise
|
||||
self.answer_override = None # what a write answers instead
|
||||
|
||||
def payload_of(self, number):
|
||||
return {"number": number, "state": self.states[number],
|
||||
"title": "A thing", "updated_at": "2026-08-11T00:00:00Z",
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number)}
|
||||
|
||||
def writes(self):
|
||||
return [c for c in self.calls if c[0] != "GET"]
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, out_root=None, allow_fail=False):
|
||||
self.calls.append((method, endpoint, payload))
|
||||
path = endpoint.split("?")[0]
|
||||
|
||||
if "/issues/" in path and method == "PATCH":
|
||||
number = int(path.rsplit("/", 1)[1])
|
||||
if self.raise_on_write is not None:
|
||||
raise self.raise_on_write
|
||||
self.states.setdefault(number, "open")
|
||||
if "state" in (payload or {}):
|
||||
self.states[number] = payload["state"]
|
||||
if self.answer_override is not None:
|
||||
return self.answer_override
|
||||
return self.payload_of(number)
|
||||
|
||||
if "/issues/" in path and method == "GET":
|
||||
n = int(path.rsplit("/", 1)[1])
|
||||
return self.payload_of(n) if n in self.states else None
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class StoreTestCase(unittest.TestCase):
|
||||
"""A temp store and a fake tracker."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory(prefix="tea-close-")
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = tmp.name
|
||||
self.fake = FakeTracker()
|
||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
# -- fixtures ----------------------------------------------------------
|
||||
|
||||
def synced(self, id="a-thing", number=101, state="open"):
|
||||
"""An issue that is in the tracker and on disk, the way a pull leaves
|
||||
it: `origin: gitea`, a `gitea:` field, and a ledger entry."""
|
||||
key = gmap.remote_key(REPO, number)
|
||||
iss = issue.Issue(id=id, title="A thing", body=BODY, state=state,
|
||||
labels=["type/task"], origin=gmap.ORIGIN,
|
||||
extra={"gitea": key, "url": "https://git.example/x",
|
||||
"synced": "2026-08-10T00:00:00Z"})
|
||||
issue.save(self.root, iss)
|
||||
m = _gitea.load_map(self.root)
|
||||
m[key] = id
|
||||
_gitea.save_map(self.root, m)
|
||||
self.fake.states[number] = state
|
||||
return iss
|
||||
|
||||
def local_only(self, id="local-thing"):
|
||||
"""An issue that has never left this machine."""
|
||||
iss = issue.Issue(id=id, title="Local thing", body=BODY,
|
||||
labels=["type/task"])
|
||||
issue.save(self.root, iss)
|
||||
return iss
|
||||
|
||||
def dropped(self, id="gone-thing", number=205, state="open"):
|
||||
"""Pushed, and its file went with the push: ledger only."""
|
||||
m = _gitea.load_map(self.root)
|
||||
m[gmap.remote_key(REPO, number)] = id
|
||||
_gitea.save_map(self.root, m)
|
||||
self.fake.states[number] = state
|
||||
return number
|
||||
|
||||
# -- runner ------------------------------------------------------------
|
||||
|
||||
def run_close(self, *argv):
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
args = ["close.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err):
|
||||
close.main()
|
||||
return self.out.getvalue(), self.err.getvalue()
|
||||
|
||||
# -- assertions --------------------------------------------------------
|
||||
|
||||
def state_on_disk(self, id):
|
||||
return issue.load(self.root, id).state
|
||||
|
||||
def raw(self, id):
|
||||
with open(issue.path_of(self.root, id)) as f:
|
||||
return f.read()
|
||||
|
||||
def assertUnchanged(self, id, before, why=""):
|
||||
self.assertEqual(self.raw(id), before,
|
||||
"%s.md was rewritten%s" % (id, why and " — " + why))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# it closes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ClosesTest(StoreTestCase):
|
||||
|
||||
def test_a_slug_closes_the_issue_it_names(self):
|
||||
self.synced("a-thing", 101)
|
||||
out, _ = self.run_close("a-thing")
|
||||
self.assertEqual(self.fake.states[101], "closed")
|
||||
self.assertIn("closed a-thing #101", out)
|
||||
|
||||
def test_the_local_state_follows(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||
|
||||
def test_only_the_state_is_sent(self):
|
||||
"""Closing is not an edit: no title, no body, no labels ride along."""
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing")
|
||||
writes = self.fake.writes()
|
||||
self.assertEqual(len(writes), 1)
|
||||
method, endpoint, payload = writes[0]
|
||||
self.assertEqual((method, endpoint), ("PATCH", "%s/issues/101" % BASE))
|
||||
self.assertEqual(payload, {"state": "closed"})
|
||||
|
||||
def test_a_number_closes_it_too(self):
|
||||
"""The normal case for a pushed issue — the file is long gone."""
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("101")
|
||||
self.assertEqual(self.fake.states[101], "closed")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||
|
||||
def test_every_key_form_is_accepted(self):
|
||||
forms = {110: "110", 111: "#111", 112: "%s#112" % REPO,
|
||||
113: "https://git.example/%s/issues/113" % REPO}
|
||||
for n in forms:
|
||||
self.fake.states[n] = "open"
|
||||
for n, arg in forms.items():
|
||||
with self.subTest(arg=arg):
|
||||
self.run_close(arg)
|
||||
self.assertEqual(self.fake.states[n], "closed")
|
||||
|
||||
def test_several_ids_in_one_run(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.synced("b-thing", 102)
|
||||
self.run_close("a-thing", "102")
|
||||
self.assertEqual(self.fake.states, {101: "closed", 102: "closed"})
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "closed")
|
||||
self.assertEqual(self.state_on_disk("b-thing"), "closed")
|
||||
|
||||
def test_the_same_issue_named_twice_is_written_once(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing", "#101")
|
||||
self.assertEqual(len(self.fake.writes()), 1)
|
||||
|
||||
def test_the_index_is_rebuilt(self):
|
||||
self.synced("a-thing", 101)
|
||||
out, _ = self.run_close("a-thing")
|
||||
self.assertIn("index:", out)
|
||||
with open(os.path.join(self.root, "INDEX.md")) as f:
|
||||
self.assertIn("closed", f.read())
|
||||
|
||||
def test_the_body_survives_untouched(self):
|
||||
"""One metadata field changes; the prose and the ticks do not."""
|
||||
self.synced("a-thing", 101)
|
||||
before = issue.load(self.root, "a-thing").body
|
||||
self.run_close("a-thing")
|
||||
self.assertEqual(issue.load(self.root, "a-thing").body, before)
|
||||
|
||||
def test_synced_is_refreshed(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing")
|
||||
iss = issue.load(self.root, "a-thing")
|
||||
self.assertNotEqual(iss.extra.get("synced"), "2026-08-10T00:00:00Z")
|
||||
self.assertEqual(iss.extra.get("remote-updated"), "2026-08-11T00:00:00Z")
|
||||
|
||||
def test_an_issue_whose_file_was_dropped_still_closes(self):
|
||||
"""No local copy at all: the ledger names it, the tracker takes it, and
|
||||
nothing is written locally."""
|
||||
self.dropped("gone-thing", 205)
|
||||
out, _ = self.run_close("gone-thing")
|
||||
self.assertEqual(self.fake.states[205], "closed")
|
||||
self.assertIn("no local copy", out)
|
||||
self.assertNotIn("index:", out)
|
||||
|
||||
def test_a_number_nobody_here_knows_closes_without_a_slug(self):
|
||||
self.fake.states[777] = "open"
|
||||
out, _ = self.run_close("777")
|
||||
self.assertEqual(self.fake.states[777], "closed")
|
||||
self.assertIn("#777", out)
|
||||
|
||||
|
||||
class ReopensTest(StoreTestCase):
|
||||
|
||||
def test_reopen_sends_open(self):
|
||||
self.synced("a-thing", 101, state="closed")
|
||||
out, _ = self.run_close("--reopen", "a-thing")
|
||||
self.assertEqual(self.fake.writes()[0][2], {"state": "open"})
|
||||
self.assertIn("reopened a-thing #101", out)
|
||||
|
||||
def test_reopen_writes_the_local_state_back(self):
|
||||
self.synced("a-thing", 101, state="closed")
|
||||
self.run_close("--reopen", "a-thing")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||
|
||||
def test_close_then_reopen_is_a_round_trip(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing")
|
||||
self.run_close("--reopen", "a-thing")
|
||||
self.assertEqual(self.fake.states[101], "open")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# it refuses
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class LocalOnlyTest(StoreTestCase):
|
||||
"""An `origin: local` issue is not in the tracker, so it cannot be closed
|
||||
there — and the local field is not quietly edited instead."""
|
||||
|
||||
def test_it_exits(self):
|
||||
self.local_only("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("local-thing")
|
||||
|
||||
def test_the_error_names_the_id_and_says_it_is_not_in_the_tracker(self):
|
||||
self.local_only("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("local-thing")
|
||||
err = self.err.getvalue()
|
||||
self.assertIn("local-thing", err)
|
||||
self.assertIn("not in the tracker", err)
|
||||
|
||||
def test_nothing_is_sent(self):
|
||||
self.local_only("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("local-thing")
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_the_file_is_untouched(self):
|
||||
self.local_only("local-thing")
|
||||
before = self.raw("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("local-thing")
|
||||
self.assertUnchanged("local-thing", before)
|
||||
|
||||
def test_a_bad_id_stops_the_whole_run_before_anything_is_sent(self):
|
||||
"""Resolution happens up front, so a typo in the second id does not
|
||||
leave the first one closed."""
|
||||
self.synced("a-thing", 101)
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing", "local-thing")
|
||||
self.assertEqual(self.fake.states[101], "open")
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_an_unknown_slug_exits(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("no-such-thing")
|
||||
self.assertIn("no-such-thing", self.err.getvalue())
|
||||
|
||||
|
||||
class DryRunTest(StoreTestCase):
|
||||
|
||||
def test_not_one_request_is_made(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("--dry-run", "a-thing")
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_the_file_is_untouched(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.run_close("--dry-run", "a-thing")
|
||||
self.assertUnchanged("a-thing", before, "--dry-run must write nothing")
|
||||
|
||||
def test_it_says_what_would_be_closed(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.synced("b-thing", 102)
|
||||
out, _ = self.run_close("--dry-run", "a-thing", "102")
|
||||
self.assertIn("would close a-thing #101", out)
|
||||
self.assertIn("would close b-thing #102", out)
|
||||
self.assertIn("2 issue(s) would be closed", out)
|
||||
|
||||
def test_it_says_reopen_under_reopen(self):
|
||||
self.synced("a-thing", 101, state="closed")
|
||||
out, _ = self.run_close("--dry-run", "--reopen", "a-thing")
|
||||
self.assertIn("would reopen a-thing #101", out)
|
||||
self.assertIn("would be reopened", out)
|
||||
|
||||
def test_it_needs_no_login(self):
|
||||
"""A dry run must work before /tea:auth has ever been run."""
|
||||
self.synced("a-thing", 101)
|
||||
with mock.patch.object(_gitea, "require_login",
|
||||
lambda: self.fail("dry run asked for a login")):
|
||||
self.run_close("--dry-run", "a-thing")
|
||||
|
||||
def test_a_local_only_issue_is_still_refused(self):
|
||||
self.local_only("local-thing")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("--dry-run", "local-thing")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the tracker said no
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TrackerFailureTest(StoreTestCase):
|
||||
"""The criterion that matters most: a write that was not confirmed leaves
|
||||
the local file exactly as it was."""
|
||||
|
||||
def test_a_non_2xx_answer_leaves_the_file(self):
|
||||
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
|
||||
or a 500 actually takes, and it ends in `die()`."""
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
|
||||
def fake_run(cmd, capture_output=False, text=False):
|
||||
return types.SimpleNamespace(
|
||||
returncode=1, stdout="",
|
||||
stderr="422 Unprocessable Entity: issue is blocked")
|
||||
|
||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||
mock.patch.object(_gitea, "subprocess",
|
||||
types.SimpleNamespace(run=fake_run)), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
|
||||
self.assertUnchanged("a-thing", before, "tea exited non-zero")
|
||||
self.assertEqual(self.state_on_disk("a-thing"), "open")
|
||||
|
||||
def test_a_transport_exception_leaves_the_file(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.raise_on_write = OSError("tea: command not found")
|
||||
with self.assertRaises(OSError):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before, "the transport raised")
|
||||
|
||||
def test_an_answer_without_a_number_leaves_the_file(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.answer_override = {"ok": True, "state": "closed"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before)
|
||||
|
||||
def test_an_answer_for_another_issue_leaves_the_file(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.answer_override = {"number": 999, "state": "closed"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before)
|
||||
|
||||
def test_an_answer_that_did_not_change_the_state_leaves_the_file(self):
|
||||
"""A 200 that still says `open` is not a close."""
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.answer_override = {"number": 101, "state": "open"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before)
|
||||
|
||||
def test_an_empty_answer_leaves_the_file(self):
|
||||
self.synced("a-thing", 101)
|
||||
before = self.raw("a-thing")
|
||||
self.fake.answer_override = None
|
||||
real_api = self.fake.api
|
||||
self.fake.api = lambda *a, **kw: (real_api(*a, **kw), None)[1]
|
||||
with mock.patch.object(_gitea, "api", self.fake.api), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertUnchanged("a-thing", before)
|
||||
|
||||
def test_the_error_says_nothing_local_changed(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.fake.answer_override = {"ok": True}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_close("a-thing")
|
||||
self.assertIn("Nothing local was changed", self.err.getvalue())
|
||||
|
||||
def test_a_failure_partway_through_keeps_the_rest(self):
|
||||
"""Two issues, the second one is not confirmed. The first is
|
||||
legitimately closed; the second's file still says open."""
|
||||
self.synced("aaa-thing", 101)
|
||||
self.synced("zzz-thing", 102)
|
||||
before = self.raw("zzz-thing")
|
||||
|
||||
real = self.fake.api
|
||||
seen = []
|
||||
|
||||
def once(login, endpoint, method="GET", payload=None, **kw):
|
||||
got = real(login, endpoint, method, payload, **kw)
|
||||
if method != "GET":
|
||||
seen.append(endpoint)
|
||||
return {"nope": True} if len(seen) > 1 else got
|
||||
|
||||
with mock.patch.object(_gitea, "api", once), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_close("aaa-thing", "zzz-thing")
|
||||
|
||||
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
|
||||
self.assertUnchanged("zzz-thing", before, "its write was not confirmed")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the pure parts
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ConfirmedTest(unittest.TestCase):
|
||||
"""The gate itself. Everything below it rewrites a file."""
|
||||
|
||||
def test_a_matching_close_is_confirmed(self):
|
||||
self.assertTrue(close.confirmed({"number": 42, "state": "closed"}, 42, "closed"))
|
||||
|
||||
def test_a_mismatched_number_is_not(self):
|
||||
self.assertFalse(close.confirmed({"number": 43, "state": "closed"}, 42, "closed"))
|
||||
|
||||
def test_the_wrong_state_is_not(self):
|
||||
self.assertFalse(close.confirmed({"number": 42, "state": "open"}, 42, "closed"))
|
||||
|
||||
def test_a_missing_state_is_not(self):
|
||||
self.assertFalse(close.confirmed({"number": 42}, 42, "closed"))
|
||||
|
||||
def test_none_and_lists_are_not(self):
|
||||
self.assertFalse(close.confirmed(None, 42, "closed"))
|
||||
self.assertFalse(close.confirmed([{"number": 42, "state": "closed"}], 42, "closed"))
|
||||
|
||||
def test_true_is_not_a_number(self):
|
||||
self.assertFalse(close.confirmed({"number": True, "state": "closed"}, 1, "closed"))
|
||||
|
||||
def test_a_string_number_is_not(self):
|
||||
self.assertFalse(close.confirmed({"number": "42", "state": "closed"}, 42, "closed"))
|
||||
|
||||
|
||||
class KeyFormTest(unittest.TestCase):
|
||||
"""A slug and a key are two vocabularies that must not collide."""
|
||||
|
||||
def test_keys_are_keys(self):
|
||||
for k in ("42", "#42", "owner/repo#42",
|
||||
"https://git.example/owner/repo/issues/42"):
|
||||
self.assertTrue(close.looks_like_key(k), k)
|
||||
|
||||
def test_slugs_are_not_keys(self):
|
||||
for s in ("a-thing", "wire-sqlc-appclick", "close-issues-through-a-script"):
|
||||
self.assertFalse(close.looks_like_key(s), s)
|
||||
|
||||
|
||||
class LedgerPairsTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.m = {"%s#7" % REPO: "a-thing", "other/repo#7": "b-thing",
|
||||
"not-a-key": "c-thing"}
|
||||
|
||||
def test_it_filters_by_repo(self):
|
||||
self.assertEqual(close.ledger_pairs(self.m, REPO), [(REPO, 7, "a-thing")])
|
||||
|
||||
def test_without_a_repo_it_keeps_everything_parseable(self):
|
||||
got = close.ledger_pairs(self.m)
|
||||
self.assertEqual(sorted(s for _r, _n, s in got), ["a-thing", "b-thing"])
|
||||
|
||||
def test_an_ambiguous_number_exits(self):
|
||||
pairs = close.ledger_pairs(self.m)
|
||||
with self.assertRaises(SystemExit):
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
close.resolve("7", {}, pairs)
|
||||
|
||||
|
||||
class AmbiguityTest(StoreTestCase):
|
||||
"""Two repos, one number, no --repo: settle it rather than guess."""
|
||||
|
||||
def test_the_error_points_at_repo(self):
|
||||
_gitea.save_map(self.root, {"%s#7" % REPO: "a-thing",
|
||||
"other/repo#7": "b-thing"})
|
||||
err = io.StringIO()
|
||||
args = ["close.py", "--out", self.root, "7"]
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(err), \
|
||||
self.assertRaises(SystemExit):
|
||||
close.main()
|
||||
self.assertIn("--repo", err.getvalue())
|
||||
|
||||
|
||||
class RepoOfTheKeyTest(StoreTestCase):
|
||||
"""A key that names its own repo is sent there, not to whatever repo the
|
||||
CWD happens to be — otherwise `#42` closes somebody else's issue."""
|
||||
|
||||
def run_bare(self, *argv):
|
||||
"""No `--repo`, so the ids have to say where they live."""
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
args = ["close.py", "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err):
|
||||
close.main()
|
||||
return self.out.getvalue(), self.err.getvalue()
|
||||
|
||||
def test_a_foreign_key_goes_to_its_own_repo(self):
|
||||
self.run_bare("other/repo#42")
|
||||
self.assertEqual(self.fake.writes()[0][1], "repos/other/repo/issues/42")
|
||||
|
||||
def test_a_slug_goes_to_the_repo_its_gitea_field_names(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_bare("a-thing")
|
||||
self.assertEqual(self.fake.writes()[0][1], "%s/issues/101" % BASE)
|
||||
|
||||
def test_two_repos_in_one_run_is_a_question_not_a_guess(self):
|
||||
self.synced("a-thing", 101)
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_bare("a-thing", "other/repo#42")
|
||||
self.assertIn("one repo", self.err.getvalue())
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_an_explicit_repo_settles_it(self):
|
||||
self.synced("a-thing", 101)
|
||||
self.run_close("a-thing", "other/repo#42")
|
||||
self.assertEqual({c[1] for c in self.fake.writes()},
|
||||
{"%s/issues/101" % BASE, "%s/issues/42" % BASE})
|
||||
|
||||
|
||||
class NoStoreTest(StoreTestCase):
|
||||
"""A number needs no local file, and a store that is not there is not an
|
||||
error — closing an issue whose copy push dropped is the normal case."""
|
||||
|
||||
def test_a_number_closes_with_no_store_at_all(self):
|
||||
missing = os.path.join(self.root, "nowhere")
|
||||
self.fake.states[303] = "open"
|
||||
args = ["close.py", "--repo", REPO, "--out", missing, "303"]
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
close.main()
|
||||
self.assertEqual(self.fake.states[303], "closed")
|
||||
self.assertFalse(os.path.isdir(missing), "no store was conjured")
|
||||
|
||||
|
||||
class PayloadFileTest(StoreTestCase):
|
||||
"""The request body goes to the transport's own scratchpad.
|
||||
|
||||
Not to a directory this script picks: `close.py` names the payload and
|
||||
nothing else, the way every other caller does. Where PAYLOAD_ROOT lands is
|
||||
_gitea's business, and test_payload_root.py is where that is tested."""
|
||||
|
||||
def test_the_payload_lands_in_the_transports_scratchpad(self):
|
||||
self.synced("a-thing", 101)
|
||||
payloads = os.path.join(self.root, "payload")
|
||||
with mock.patch.object(_gitea, "PAYLOAD_ROOT", payloads), \
|
||||
mock.patch.object(_gitea, "api", REAL_API), \
|
||||
mock.patch.object(
|
||||
_gitea, "subprocess",
|
||||
types.SimpleNamespace(run=lambda cmd, **kw: types.SimpleNamespace(
|
||||
returncode=0, stderr="",
|
||||
stdout=json.dumps({"number": 101, "state": "closed"})))):
|
||||
self.run_close("a-thing")
|
||||
p = os.path.join(payloads, "state-101.json")
|
||||
self.assertTrue(os.path.isfile(p))
|
||||
with open(p) as f:
|
||||
self.assertEqual(json.load(f), {"state": "closed"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
The container <-> child edge points ONE way: container -> child.
|
||||
|
||||
A `type/feature` is closed when its children are closed, and that is a
|
||||
dependency relation, so the container lists its children in `depends:`. A child
|
||||
belongs to a feature, which is a membership relation, and membership has no
|
||||
place in a dependency graph — so a child never names its container back. These
|
||||
tests pin that direction down in all three places it shows up: the validator,
|
||||
the desync warning, and the drawn tree.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib only, like the scripts under test. `skills/*/scripts/` are directories,
|
||||
not packages, so they go on sys.path by hand.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
import issue # noqa: E402
|
||||
import issue_check # noqa: E402
|
||||
import issue_new # noqa: E402
|
||||
import issue_tree # noqa: E402
|
||||
|
||||
|
||||
def run(module, argv):
|
||||
"""Call a script's main() with argv, returning (exit_code, stdout).
|
||||
|
||||
stderr is swallowed: issue_new.py notes on it when a `--depends` id is not
|
||||
in the store yet, which is fine and not what these tests are about."""
|
||||
buf = io.StringIO()
|
||||
old = sys.argv
|
||||
sys.argv = [module.__name__ + ".py"] + argv
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
|
||||
code = module.main()
|
||||
except SystemExit as e: # argparse / sys.exit("msg")
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
finally:
|
||||
sys.argv = old
|
||||
return (code or 0), buf.getvalue()
|
||||
|
||||
|
||||
def drawn(tree_output):
|
||||
"""The rows inside the tree's code fence, header and blanks dropped."""
|
||||
return [l for l in tree_output.splitlines() if l.rstrip().endswith(".md")]
|
||||
|
||||
|
||||
class StoreCase(unittest.TestCase):
|
||||
"""A scratch store per test. Never touches tmp/issues/."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = self._tmp.name
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
|
||||
def new(self, type, id, title, depends=()):
|
||||
argv = ["--type", type, "--id", id, "--title", title, "--out", self.root]
|
||||
for d in depends:
|
||||
argv += ["--depends", d]
|
||||
code, _ = run(issue_new, argv)
|
||||
self.assertEqual(code, 0, "issue_new.py failed for %s" % id)
|
||||
|
||||
def edit(self, id, old, new):
|
||||
p = issue.path_of(self.root, id)
|
||||
with open(p) as f:
|
||||
text = f.read()
|
||||
self.assertIn(old, text, "%s.md does not contain %r" % (id, old))
|
||||
with open(p, "w") as f:
|
||||
f.write(text.replace(old, new))
|
||||
|
||||
def fill_issues_section(self, id, *children):
|
||||
"""Replace the type/feature template's `## Issues` placeholder."""
|
||||
self.edit(id,
|
||||
"- [ ] slug-дочернего-issue — краткое описание части\n- [ ] …\n",
|
||||
"".join("- [ ] %s — часть\n" % c for c in children))
|
||||
|
||||
def container_and_child(self):
|
||||
"""The canonical shape from references/format.md: the container names
|
||||
the child in `depends:` AND in `## Issues`; the child names nobody."""
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.new("feature", "feat-x", "Container x", depends=["child-y"])
|
||||
self.fill_issues_section("feat-x", "child-y")
|
||||
|
||||
|
||||
class CanonicalContainerIsClean(StoreCase):
|
||||
"""A container from the template plus a child per format.md: green."""
|
||||
|
||||
def test_check_is_silent_and_exits_zero(self):
|
||||
self.container_and_child()
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertNotIn("ERROR", out)
|
||||
self.assertNotIn("warn", out)
|
||||
self.assertIn("ok feat-x", out)
|
||||
self.assertIn("ok child-y", out)
|
||||
|
||||
def test_validate_reports_nothing_for_either_issue(self):
|
||||
self.container_and_child()
|
||||
issues = issue.load_all(self.root)
|
||||
for id in ("feat-x", "child-y"):
|
||||
err, warn = issue.validate(issues[id], known_ids=set(issues))
|
||||
self.assertEqual((err, warn), ([], []), id)
|
||||
|
||||
def test_the_child_does_not_depend_on_its_container(self):
|
||||
self.container_and_child()
|
||||
issues = issue.load_all(self.root)
|
||||
self.assertEqual(issues["feat-x"].depends, ["child-y"])
|
||||
self.assertEqual(issues["child-y"].depends, [])
|
||||
|
||||
|
||||
class OnlyOneDirectionIsLegal(StoreCase):
|
||||
"""format.md and the validator agree on container -> child, and the
|
||||
reverse edge is an error rather than a matter of taste."""
|
||||
|
||||
def test_the_reverse_edge_is_a_cycle(self):
|
||||
self.container_and_child()
|
||||
self.edit("child-y", "depends: []", "depends: [feat-x]")
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 1, out)
|
||||
self.assertIn("ERROR cycle:", out)
|
||||
self.assertIn("feat-x", out)
|
||||
self.assertIn("child-y", out)
|
||||
|
||||
def test_a_child_pointing_at_its_container_alone_is_not_the_graph(self):
|
||||
"""The shape format.md used to document: the child depends on the
|
||||
container and the container's depends: is empty. It no longer matches
|
||||
what the container's own `## Issues` says, so the check complains."""
|
||||
self.new("feature", "feat-x", "Container x")
|
||||
self.new("task", "child-y", "Child y", depends=["feat-x"])
|
||||
self.fill_issues_section("feat-x", "child-y")
|
||||
_, out = run(issue_check, ["--out", self.root])
|
||||
self.assertIn("warn feat-x:", out)
|
||||
|
||||
def test_issues_section_is_an_edge_source_pointing_down(self):
|
||||
body = "## Issues\n- [ ] child-y — часть\n"
|
||||
self.assertEqual(issue.body_dep_refs(body), ["child-y"])
|
||||
|
||||
|
||||
class WarningNamesItsOwnSection(StoreCase):
|
||||
"""The desync warning quotes the section the reference came from, not
|
||||
`## Depends on` unconditionally — a container has no such section."""
|
||||
|
||||
def test_container_warning_says_issues(self):
|
||||
self.new("feature", "feat-x", "Container x")
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.fill_issues_section("feat-x", "child-y") # but not depends:
|
||||
issues = issue.load_all(self.root)
|
||||
err, warn = issue.validate(issues["feat-x"], known_ids=set(issues))
|
||||
self.assertEqual(err, [])
|
||||
self.assertEqual(
|
||||
warn, ["## Issues mentions 'child-y' but `depends:` does not list it"])
|
||||
self.assertNotIn("## Depends on", "\n".join(warn))
|
||||
body = issue.load(self.root, "feat-x").body
|
||||
self.assertNotIn("## Depends on", body,
|
||||
"the warning must not name a section that is not in the file")
|
||||
|
||||
def test_plain_issue_warning_still_says_depends_on(self):
|
||||
self.new("task", "child-y", "Child y", depends=["migrate-schema"])
|
||||
self.edit("child-y", "depends: [migrate-schema]", "depends: []")
|
||||
issues = issue.load_all(self.root)
|
||||
_, warn = issue.validate(issues["child-y"])
|
||||
self.assertEqual(
|
||||
warn,
|
||||
["## Depends on mentions 'migrate-schema' but `depends:` does not list it"])
|
||||
|
||||
def test_each_reference_is_named_with_its_own_section(self):
|
||||
body = ("## Depends on\n- migrate-schema\n\n"
|
||||
"## Issues\n- [ ] child-y — часть\n")
|
||||
self.assertEqual(
|
||||
issue.body_dep_ref_sections(body),
|
||||
[("## Depends on", "migrate-schema"), ("## Issues", "child-y")])
|
||||
|
||||
def test_body_dep_refs_still_returns_bare_strings(self):
|
||||
"""skills/sync/scripts/map.py filters this list for `#N` refs."""
|
||||
body = "## Depends on\n- migrate-schema\n- #42\n"
|
||||
refs = issue.body_dep_refs(body)
|
||||
self.assertEqual(refs, ["migrate-schema", "#42"])
|
||||
self.assertTrue(all(isinstance(r, str) for r in refs))
|
||||
|
||||
def test_tracker_numbers_never_warn(self):
|
||||
"""`#42` is a tracker handle, not a slug; `depends:` holds ids only."""
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.edit("child-y", "## Motivation", "## Depends on\n- #42\n\n## Motivation")
|
||||
issues = issue.load_all(self.root)
|
||||
_, warn = issue.validate(issues["child-y"])
|
||||
self.assertEqual(warn, [])
|
||||
|
||||
|
||||
class TreePutsTheContainerOnTop(StoreCase):
|
||||
|
||||
def test_container_is_the_root_and_children_hang_below(self):
|
||||
self.container_and_child()
|
||||
code, out = run(issue_tree, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
rows = drawn(out)
|
||||
self.assertEqual(len(rows), 2, out)
|
||||
self.assertTrue(rows[0].startswith("feat-x "), out)
|
||||
self.assertTrue(rows[1].startswith("└── child-y "), out)
|
||||
self.assertEqual(out.count("child-y ["), 1, "child drawn more than once")
|
||||
|
||||
def test_the_container_is_the_only_root(self):
|
||||
self.container_and_child()
|
||||
_, out = run(issue_tree, ["--out", self.root])
|
||||
self.assertIn("# Dependency tree — feat-x", out)
|
||||
|
||||
def test_two_children_hang_off_one_container(self):
|
||||
self.new("task", "child-y", "Child y")
|
||||
self.new("task", "child-z", "Child z")
|
||||
self.new("feature", "feat-x", "Container x",
|
||||
depends=["child-y", "child-z"])
|
||||
self.fill_issues_section("feat-x", "child-y", "child-z")
|
||||
code, out = run(issue_check, ["--out", self.root])
|
||||
self.assertEqual(code, 0, out)
|
||||
_, tree = run(issue_tree, ["--out", self.root])
|
||||
self.assertEqual(tree.count("feat-x ["), 1,
|
||||
"the container must not repeat once per child")
|
||||
self.assertEqual([r.split(" ")[0] for r in drawn(tree)],
|
||||
["feat-x", "├──", "└──"], tree)
|
||||
self.assertIn("├── child-y ", tree)
|
||||
self.assertIn("└── child-z ", tree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,811 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
The local copy is dropped after a successful push, and pulled back on demand.
|
||||
|
||||
Two halves, and the second one is the one that matters:
|
||||
|
||||
1. **It deletes.** A confirmed create or PATCH removes `tmp/issues/<id>.md` and
|
||||
`<id>.comments.md`, prints where the issue lives now, and leaves the ledger
|
||||
behind so the slug can be found again. A pull puts the same file back —
|
||||
same slug, same `depends:`, same body — including after a rename in Gitea
|
||||
and on a machine that never had the file.
|
||||
|
||||
2. **It does not delete anything else, ever.** A transport that raised, a `tea`
|
||||
that exited non-zero, an answer without a number, an answer for the wrong
|
||||
issue, an `origin: local` issue nobody pushed: the file is still on disk.
|
||||
A bug here destroys work, so every one of those paths is asserted
|
||||
separately, and the assertion is always the same — `os.path.isfile`.
|
||||
|
||||
The transport is stubbed at `_gitea.api`, as `test_push_dependencies.py` does,
|
||||
with one deliberate exception: the non-2xx test stubs `_gitea.subprocess`
|
||||
instead and lets the REAL `_gitea.api` run, so "tea exited 1" is proved end to
|
||||
end rather than assumed.
|
||||
|
||||
Nothing here touches a network, and nothing here touches the developer's store:
|
||||
every test builds its own in a `tempfile.mkdtemp()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
import pull # noqa: E402
|
||||
import push # noqa: E402
|
||||
|
||||
# Captured before any test patches it — the non-2xx test needs the real thing.
|
||||
REAL_API = _gitea.api
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
LABELS = {"type/task": 901, "type/bug": 902}
|
||||
LABEL_NAMES = {v: k for k, v in LABELS.items()}
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
BODY_WITH_DEPS = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Depends on
|
||||
- first-thing — ставит фундамент, без него второй не собрать
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# a tracker that can be both pushed to and pulled from
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from memory, for push AND pull.
|
||||
|
||||
It keeps bodies the way Gitea does — verbatim, marker and all — which is
|
||||
what makes the round-trip tests real: the slug that comes back is the one
|
||||
that was actually stored on the far side, not one the test handed over."""
|
||||
|
||||
def __init__(self, next_number=101):
|
||||
self.calls = []
|
||||
self.next_number = next_number
|
||||
self.issues = {} # number -> payload
|
||||
self.deps = {} # number -> {(repo, number)}
|
||||
# Failure injection, one write at a time.
|
||||
self.raise_on_write = None # an exception instance to raise
|
||||
self.answer_override = None # what a write answers instead
|
||||
|
||||
# -- state -------------------------------------------------------------
|
||||
|
||||
def store(self, number, title, body, **kw):
|
||||
p = {"number": number, "title": title, "body": body, "state": "open",
|
||||
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
|
||||
"milestone": None, "ref": "test-branch",
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"updated_at": "2026-08-10T00:00:00Z",
|
||||
"repository": {"full_name": REPO}}
|
||||
p.update(kw)
|
||||
self.issues[number] = p
|
||||
return p
|
||||
|
||||
def body_of(self, number):
|
||||
return self.issues[number]["body"]
|
||||
|
||||
def rename(self, number, title):
|
||||
self.issues[number]["title"] = title
|
||||
|
||||
def writes(self):
|
||||
return [c for c in self.calls if c[0] != "GET"]
|
||||
|
||||
# -- the seam ----------------------------------------------------------
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, allow_fail=False):
|
||||
self.calls.append((method, endpoint, payload))
|
||||
path = endpoint.split("?")[0]
|
||||
|
||||
if path == "%s/labels" % BASE and method == "GET":
|
||||
return [{"name": n, "id": i} for n, i in LABELS.items()]
|
||||
|
||||
if path.endswith("/comments"):
|
||||
return []
|
||||
|
||||
if path.endswith("/dependencies"):
|
||||
number = int(path.split("/issues/")[1].split("/")[0])
|
||||
if method == "GET":
|
||||
return [dict(self.issues[n], repository={"full_name": r})
|
||||
for r, n in sorted(self.deps.get(number, set()))
|
||||
if n in self.issues]
|
||||
if method == "POST":
|
||||
self.deps.setdefault(number, set()).add(
|
||||
("%s/%s" % (payload["owner"], payload["repo"]),
|
||||
int(payload["index"])))
|
||||
return {"number": number}
|
||||
|
||||
if path == "%s/issues" % BASE and method == "POST":
|
||||
return self._write(
|
||||
lambda: self.store(self._next(), payload.get("title", ""),
|
||||
payload.get("body", ""),
|
||||
labels=self._labels(payload),
|
||||
ref=payload.get("ref", "")))
|
||||
|
||||
if "/issues/" in path and method == "PATCH":
|
||||
number = int(path.rsplit("/", 1)[1])
|
||||
return self._write(
|
||||
lambda: self.store(number, payload.get("title", ""),
|
||||
payload.get("body", ""),
|
||||
labels=self._labels(payload),
|
||||
ref=payload.get("ref", "")))
|
||||
|
||||
if "/issues/" in path and method == "GET":
|
||||
return self.issues.get(int(path.rsplit("/", 1)[1]))
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
def _next(self):
|
||||
n = self.next_number
|
||||
self.next_number += 1
|
||||
return n
|
||||
|
||||
def _labels(self, payload):
|
||||
return [{"name": LABEL_NAMES[i]} for i in (payload or {}).get("labels") or []
|
||||
if i in LABEL_NAMES]
|
||||
|
||||
def _write(self, do):
|
||||
"""Every create and update goes through here, so a test can make one
|
||||
fail without knowing which verb it was."""
|
||||
if self.raise_on_write is not None:
|
||||
raise self.raise_on_write
|
||||
got = do()
|
||||
if self.answer_override is not None:
|
||||
return self.answer_override
|
||||
return got
|
||||
|
||||
|
||||
class StoreTestCase(unittest.TestCase):
|
||||
"""A temp store, a fake tracker, and no git."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="tea-drop-")
|
||||
self.fake = FakeTracker()
|
||||
# PAYLOAD_ROOT is the repo's own tmp/payload, and a test that stubs the
|
||||
# transport one layer down (see the non-2xx case) reaches the real
|
||||
# write. Point it at the fixture: a test writes in its temp directory
|
||||
# and nowhere else.
|
||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||
mock.patch.object(_gitea, "PAYLOAD_ROOT",
|
||||
os.path.join(self.root, "payload")),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
|
||||
mock.patch.object(push, "git_branch", lambda: "test-branch")):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
|
||||
# -- fixtures ----------------------------------------------------------
|
||||
|
||||
def write_issue(self, id, title, body=BODY, depends=(), origin=issue.LOCAL,
|
||||
extra=None):
|
||||
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
||||
depends=list(depends), origin=origin,
|
||||
extra=dict(extra or {}))
|
||||
issue.save(self.root, iss)
|
||||
return iss
|
||||
|
||||
def write_comments(self, id, text="## comment 1 — someone — 2026-08-10\n\nтекст\n"):
|
||||
p = _gitea.comments_path(self.root, id)
|
||||
with open(p, "w") as f:
|
||||
f.write(text)
|
||||
return p
|
||||
|
||||
# -- runners -----------------------------------------------------------
|
||||
|
||||
def run_push(self, *argv):
|
||||
return self._run(push, "push.py", argv)
|
||||
|
||||
def run_pull(self, *argv):
|
||||
return self._run(pull, "pull.py", argv)
|
||||
|
||||
def _run(self, mod, name, argv):
|
||||
# Kept on self so a test that expects SystemExit can still read what
|
||||
# went to stderr — the run never returns in that case.
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err):
|
||||
mod.main()
|
||||
return self.out.getvalue(), self.err.getvalue()
|
||||
|
||||
# -- assertions --------------------------------------------------------
|
||||
|
||||
def assertOnDisk(self, id, why=""):
|
||||
self.assertTrue(os.path.isfile(issue.path_of(self.root, id)),
|
||||
"%s.md was deleted%s" % (id, why and " — " + why))
|
||||
|
||||
def assertGone(self, id):
|
||||
self.assertFalse(os.path.isfile(issue.path_of(self.root, id)),
|
||||
"%s.md is still on disk" % id)
|
||||
|
||||
def ledger(self):
|
||||
return _gitea.load_map(self.root)
|
||||
|
||||
def number_of(self, id):
|
||||
for key, slug in self.ledger().items():
|
||||
if slug == id:
|
||||
return gmap.parse_remote_key(key)[1]
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# it deletes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class DropsAfterCreateTest(StoreTestCase):
|
||||
|
||||
def test_the_issue_file_is_gone(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
self.assertGone("a-thing")
|
||||
|
||||
def test_the_comment_thread_goes_with_it(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
cpath = self.write_comments("a-thing")
|
||||
self.run_push()
|
||||
self.assertFalse(os.path.isfile(cpath), "the thread outlived the issue")
|
||||
|
||||
def test_a_missing_thread_is_not_an_error(self):
|
||||
"""Most issues have no comments file. Dropping must not care."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
out, _ = self.run_push()
|
||||
self.assertIn("dropped", out)
|
||||
|
||||
def test_the_output_names_the_number_and_the_url(self):
|
||||
"""The local path is gone, so this line is the only address left."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
out, _ = self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.assertIn("#%d" % n, out)
|
||||
self.assertIn("https://git.example/%s/issues/%d" % (REPO, n), out)
|
||||
self.assertIn("pull.py %d" % n, out)
|
||||
|
||||
def test_the_ledger_outlives_the_file(self):
|
||||
"""`.remote.json` does not become garbage when the files go — it
|
||||
becomes the only local record of which slug this number is."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.assertIsNotNone(n)
|
||||
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
|
||||
|
||||
def test_the_ledger_is_written_before_the_file_is_removed(self):
|
||||
"""Ordering, asserted rather than trusted: if the two were swapped, an
|
||||
interrupted run would cost the slug and not just a re-pull."""
|
||||
seen = {}
|
||||
real_drop = push.drop_local
|
||||
|
||||
def spy(root, id):
|
||||
seen["ledger"] = json.load(open(_gitea.map_path(root)))
|
||||
return real_drop(root, id)
|
||||
|
||||
self.write_issue("a-thing", "A thing")
|
||||
with mock.patch.object(push, "drop_local", spy):
|
||||
self.run_push()
|
||||
self.assertIn("a-thing", (seen.get("ledger") or {}).values())
|
||||
|
||||
|
||||
class DropsAfterUpdateTest(StoreTestCase):
|
||||
"""One rule, no exception: `--update` deletes too."""
|
||||
|
||||
def pushed_then_pulled(self, id="a-thing", body=BODY):
|
||||
self.write_issue(id, "A thing", body=body)
|
||||
self.run_push()
|
||||
self.run_pull(str(self.number_of(id)))
|
||||
self.assertOnDisk(id, "the pull should have put it back")
|
||||
return id
|
||||
|
||||
def test_patch_deletes_the_file_too(self):
|
||||
id = self.pushed_then_pulled()
|
||||
out, _ = self.run_push("--update", id)
|
||||
self.assertIn("updated", out)
|
||||
self.assertGone(id)
|
||||
|
||||
def test_patch_deletes_the_thread_too(self):
|
||||
id = self.pushed_then_pulled()
|
||||
cpath = self.write_comments(id)
|
||||
self.run_push("--update", id)
|
||||
self.assertFalse(os.path.isfile(cpath))
|
||||
|
||||
def test_the_patch_really_went_out(self):
|
||||
id = self.pushed_then_pulled()
|
||||
self.run_push("--update", id)
|
||||
self.assertTrue([c for c in self.fake.calls if c[0] == "PATCH"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# it deletes nothing else
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class NeverPushedIsNeverDroppedTest(StoreTestCase):
|
||||
|
||||
def test_a_local_issue_nobody_selected_stays(self):
|
||||
self.write_issue("pushed-thing", "Pushed thing")
|
||||
self.write_issue("kept-thing", "Kept thing")
|
||||
self.run_push("pushed-thing")
|
||||
self.assertGone("pushed-thing")
|
||||
self.assertOnDisk("kept-thing", "it was never pushed")
|
||||
|
||||
def test_a_local_only_dependency_stays(self):
|
||||
"""It is read (for the warning) but never sent, so never dropped."""
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||
depends=["first-thing"])
|
||||
_, err = self.run_push("second-thing")
|
||||
self.assertIn("depends on local-only issue(s) first-thing", err)
|
||||
self.assertOnDisk("first-thing", "it was never sent")
|
||||
|
||||
def test_dry_run_deletes_nothing(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push("--dry-run")
|
||||
self.assertOnDisk("a-thing", "--dry-run must not write or delete")
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_a_format_violation_stops_before_anything_is_sent(self):
|
||||
"""No type/* label: validation fails, nothing is sent, nothing goes."""
|
||||
issue.save(self.root, issue.Issue(id="bad-thing", title="Bad thing",
|
||||
body=BODY, labels=[]))
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push("bad-thing")
|
||||
self.assertOnDisk("bad-thing")
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
|
||||
class SurvivesEveryFailureTest(StoreTestCase):
|
||||
"""The criterion that matters most. Each path is asserted on its own."""
|
||||
|
||||
def test_a_transport_exception_leaves_the_file(self):
|
||||
"""`tea` could not be run at all — the exception propagates out of the
|
||||
push and the delete is never reached."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.raise_on_write = OSError("tea: command not found")
|
||||
with self.assertRaises(OSError):
|
||||
self.run_push()
|
||||
self.assertOnDisk("a-thing", "the transport raised")
|
||||
self.assertEqual(self.ledger(), {})
|
||||
|
||||
def test_a_non_2xx_answer_leaves_the_file(self):
|
||||
"""The real `_gitea.api` against a `tea` that exits 1.
|
||||
|
||||
Stubbed one layer lower than every other test here on purpose: this is
|
||||
the path a 422 or a 500 actually takes, and it ends in `die()`."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
|
||||
def fake_run(cmd, capture_output=False, text=False):
|
||||
creating = "-X" in cmd and cmd[cmd.index("-X") + 1] == "POST"
|
||||
if creating:
|
||||
return types.SimpleNamespace(
|
||||
returncode=1, stdout="",
|
||||
stderr="422 Unprocessable Entity: validation failed")
|
||||
if cmd[-1].split("?")[0].endswith("/labels"):
|
||||
return types.SimpleNamespace(
|
||||
returncode=0, stderr="",
|
||||
stdout=json.dumps([{"name": n, "id": i}
|
||||
for n, i in LABELS.items()]))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||
mock.patch.object(_gitea, "subprocess",
|
||||
types.SimpleNamespace(run=fake_run)), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
|
||||
self.assertOnDisk("a-thing", "tea exited non-zero")
|
||||
|
||||
def test_an_answer_without_a_number_leaves_the_file(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.answer_override = {"ok": True, "message": "created"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
self.assertOnDisk("a-thing", "the answer carried no number")
|
||||
|
||||
def test_an_answer_that_is_not_an_object_leaves_the_file(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.answer_override = ["something", "else"]
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
self.assertOnDisk("a-thing")
|
||||
|
||||
def test_an_empty_answer_leaves_the_file(self):
|
||||
"""`tea` exited 0 and printed nothing — api returns None."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.answer_override = None
|
||||
real_write = self.fake._write
|
||||
self.fake._write = lambda do: (real_write(do), None)[1]
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
self.assertOnDisk("a-thing")
|
||||
|
||||
def test_a_patch_answering_for_another_issue_leaves_the_file(self):
|
||||
"""The mismatched-body case: we PATCHed #101 and #999 answered."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.run_pull(str(n))
|
||||
self.assertOnDisk("a-thing")
|
||||
|
||||
self.fake.answer_override = {"number": 999, "html_url": "https://x"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push("--update", "a-thing")
|
||||
self.assertOnDisk("a-thing", "the tracker answered for a different issue")
|
||||
|
||||
def test_the_error_says_the_file_is_untouched(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.fake.answer_override = {"ok": True}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
self.assertIn("untouched", self.err.getvalue())
|
||||
|
||||
def test_a_failure_partway_through_keeps_what_has_not_been_sent(self):
|
||||
"""Two issues, the second one fails. The first is legitimately gone —
|
||||
Gitea confirmed it — and the second is still here."""
|
||||
self.write_issue("aaa-thing", "Aaa thing")
|
||||
self.write_issue("zzz-thing", "Zzz thing")
|
||||
|
||||
real_write = self.fake._write
|
||||
seen = []
|
||||
|
||||
def once(do):
|
||||
seen.append(1)
|
||||
if len(seen) > 1:
|
||||
return {"nope": True}
|
||||
return real_write(do)
|
||||
|
||||
self.fake._write = once
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_push()
|
||||
|
||||
self.assertGone("aaa-thing")
|
||||
self.assertOnDisk("zzz-thing", "its write never succeeded")
|
||||
# And the one that did go up is in the ledger, so it is findable.
|
||||
self.assertEqual(list(self.ledger().values()), ["aaa-thing"])
|
||||
|
||||
|
||||
class ConfirmedNumberTest(unittest.TestCase):
|
||||
"""The gate itself. Everything below it deletes a file."""
|
||||
|
||||
def test_a_plain_create_is_confirmed(self):
|
||||
self.assertEqual(push.confirmed_number({"number": 42}), 42)
|
||||
|
||||
def test_a_matching_patch_is_confirmed(self):
|
||||
self.assertEqual(push.confirmed_number({"number": 42}, 42), 42)
|
||||
|
||||
def test_a_mismatched_patch_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number({"number": 43}, 42))
|
||||
|
||||
def test_none_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number(None))
|
||||
|
||||
def test_a_list_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number([{"number": 42}]))
|
||||
|
||||
def test_a_missing_number_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number({"html_url": "https://x"}))
|
||||
|
||||
def test_a_string_number_is_not(self):
|
||||
self.assertIsNone(push.confirmed_number({"number": "42"}))
|
||||
|
||||
def test_true_is_not_a_number(self):
|
||||
"""`True` is an `int` in Python; `number: true` confirms nothing."""
|
||||
self.assertIsNone(push.confirmed_number({"number": True}))
|
||||
|
||||
def test_zero_and_negatives_are_not(self):
|
||||
self.assertIsNone(push.confirmed_number({"number": 0}))
|
||||
self.assertIsNone(push.confirmed_number({"number": -1}))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the id marker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class IdMarkerTest(unittest.TestCase):
|
||||
"""map.py, pure — no store, no tracker."""
|
||||
|
||||
def test_the_marker_is_the_first_line(self):
|
||||
got = gmap.with_id_marker("## Summary\nтекст", "a-thing")
|
||||
self.assertEqual(got.splitlines()[0], "<!-- tea:id a-thing -->")
|
||||
self.assertEqual(got.splitlines()[1], "")
|
||||
|
||||
def test_strip_is_the_exact_inverse(self):
|
||||
for body in ("## Summary\nтекст", "", "one line",
|
||||
"## Summary\n\n- [ ] пункт\n\n## Spec\nnone"):
|
||||
self.assertEqual(gmap.strip_id_marker(gmap.with_id_marker(body, "x")),
|
||||
body)
|
||||
|
||||
def test_a_body_with_no_marker_comes_back_byte_for_byte(self):
|
||||
body = "## Summary\n\n весь текст \n\n\n"
|
||||
self.assertEqual(gmap.strip_id_marker(body), body)
|
||||
|
||||
def test_marking_twice_still_leaves_one(self):
|
||||
once = gmap.with_id_marker("текст", "a-thing")
|
||||
twice = gmap.with_id_marker(once, "a-thing")
|
||||
self.assertEqual(once, twice)
|
||||
self.assertEqual(twice.count("tea:id"), 1)
|
||||
|
||||
def test_remarking_under_a_new_slug_replaces_rather_than_adds(self):
|
||||
got = gmap.with_id_marker(gmap.with_id_marker("текст", "old"), "new")
|
||||
self.assertEqual(got.count("tea:id"), 1)
|
||||
self.assertEqual(gmap.id_in_body(got), "new")
|
||||
|
||||
def test_every_marker_is_removed_not_just_the_first(self):
|
||||
"""A body hand-edited in the web UI could hold two. It comes back with
|
||||
none, and the next push writes exactly one."""
|
||||
mangled = ("<!-- tea:id one -->\n\nтекст\n\n<!-- tea:id two -->\nещё")
|
||||
self.assertEqual(gmap.strip_id_marker(mangled), "текст\n\nещё")
|
||||
self.assertEqual(gmap.with_id_marker(mangled, "one").count("tea:id"), 1)
|
||||
|
||||
def test_id_in_body_reads_the_first_marker(self):
|
||||
self.assertEqual(gmap.id_in_body("<!-- tea:id one -->\n\nx"), "one")
|
||||
self.assertIsNone(gmap.id_in_body("## Summary\nтекст"))
|
||||
self.assertIsNone(gmap.id_in_body(""))
|
||||
|
||||
def test_a_marker_that_is_not_a_slug_is_ignored(self):
|
||||
"""Better to fall back to the title than to name a file after junk."""
|
||||
for junk in ("Not A Slug", "../etc/passwd", "-leading", "два-слова"):
|
||||
self.assertIsNone(gmap.id_in_body("<!-- tea:id %s -->\n\nx" % junk))
|
||||
|
||||
def test_the_marker_tolerates_spacing(self):
|
||||
self.assertEqual(gmap.id_in_body("<!--tea:id a-thing-->"), "a-thing")
|
||||
self.assertEqual(gmap.id_in_body(" <!-- tea:id a-thing --> "),
|
||||
"a-thing")
|
||||
|
||||
def test_a_marker_inside_prose_is_not_one(self):
|
||||
"""Only a line that is nothing but the marker counts."""
|
||||
self.assertIsNone(gmap.id_in_body("см. <!-- tea:id a-thing --> выше"))
|
||||
|
||||
def test_to_payload_marks_and_from_api_unmarks(self):
|
||||
iss = issue.Issue(id="a-thing", title="A thing", body="## Summary\nтекст")
|
||||
sent = gmap.to_payload(iss)["body"]
|
||||
self.assertTrue(sent.startswith("<!-- tea:id a-thing -->"))
|
||||
back, _ = gmap.from_api({"number": 1, "title": "A thing", "body": sent},
|
||||
"a-thing", REPO)
|
||||
self.assertEqual(back.body, "## Summary\nтекст")
|
||||
|
||||
|
||||
class MarkerStaysOffDiskTest(StoreTestCase):
|
||||
|
||||
def test_the_local_file_never_holds_a_marker(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.assertIn("tea:id a-thing", self.fake.body_of(n))
|
||||
|
||||
self.run_pull(str(n))
|
||||
with open(issue.path_of(self.root, "a-thing")) as f:
|
||||
self.assertNotIn("tea:id", f.read())
|
||||
|
||||
def test_repeated_round_trips_do_not_accumulate_markers(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
for _ in range(3):
|
||||
self.run_pull(str(n))
|
||||
self.run_push("--update", "a-thing")
|
||||
self.assertEqual(self.fake.body_of(n).count("tea:id"), 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the round trip
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class RoundTripTest(StoreTestCase):
|
||||
"""push -> the file is gone -> pull -> the same file is back."""
|
||||
|
||||
def two_issues(self):
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||
depends=["first-thing"])
|
||||
|
||||
def snapshot(self, id):
|
||||
iss = issue.load(self.root, id)
|
||||
return (iss.id, iss.title, iss.body, sorted(iss.depends),
|
||||
sorted(iss.labels), iss.state)
|
||||
|
||||
def test_the_file_comes_back_identical(self):
|
||||
self.two_issues()
|
||||
before = self.snapshot("second-thing")
|
||||
self.run_push()
|
||||
self.assertGone("second-thing")
|
||||
|
||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||
self.assertEqual(self.snapshot("second-thing"), before)
|
||||
|
||||
def test_depends_survives_the_round_trip(self):
|
||||
"""The edge lives in Gitea's own graph while the files do not exist —
|
||||
push wrote it, `pull --deps` reads it back, and the ledger turns the
|
||||
number back into the slug it had here."""
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
self.assertGone("first-thing")
|
||||
self.assertGone("second-thing")
|
||||
|
||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||
self.assertEqual(issue.load(self.root, "second-thing").depends,
|
||||
["first-thing"])
|
||||
|
||||
def test_the_prose_dependency_is_still_the_authors_words(self):
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
self.run_pull(str(self.number_of("second-thing")), "--deps")
|
||||
self.assertIn("- first-thing — ставит фундамент",
|
||||
issue.load(self.root, "second-thing").body)
|
||||
|
||||
def test_a_rename_in_gitea_does_not_change_the_slug(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
|
||||
self.fake.rename(n, "Completely different title now")
|
||||
self.run_pull(str(n))
|
||||
|
||||
self.assertOnDisk("a-thing")
|
||||
self.assertFalse(os.path.isfile(
|
||||
issue.path_of(self.root, "completely-different-title-now")))
|
||||
self.assertEqual(issue.load(self.root, "a-thing").title,
|
||||
"Completely different title now")
|
||||
|
||||
def test_the_slug_survives_a_rename_with_the_ledger_thrown_away(self):
|
||||
"""The case `.remote.json` cannot cover: a fresh clone, or another
|
||||
machine. The marker is the only thing left, and it is enough."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
|
||||
self.fake.rename(n, "Completely different title now")
|
||||
os.remove(_gitea.map_path(self.root))
|
||||
|
||||
self.run_pull(str(n))
|
||||
self.assertOnDisk("a-thing")
|
||||
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
|
||||
|
||||
def test_depends_survives_a_lost_ledger_when_both_come_back(self):
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||
os.remove(_gitea.map_path(self.root))
|
||||
|
||||
self.run_pull(str(first), str(second), "--deps")
|
||||
self.assertEqual(issue.load(self.root, "second-thing").depends,
|
||||
["first-thing"])
|
||||
|
||||
def test_an_issue_filed_in_the_web_ui_still_gets_a_slug(self):
|
||||
"""No marker, no ledger entry — the title is the fallback, as before."""
|
||||
self.fake.store(500, "Filed in the web ui", "## Summary\nтекст")
|
||||
self.run_pull("500")
|
||||
self.assertOnDisk("filed-in-the-web-ui")
|
||||
|
||||
def test_a_marker_colliding_with_a_local_issue_does_not_overwrite_it(self):
|
||||
"""A slug is only taken at its word when it is free."""
|
||||
self.write_issue("a-thing", "A thing", body="## Summary\nмоя локальная")
|
||||
self.fake.store(500, "Something else",
|
||||
gmap.with_id_marker("## Summary\nчужая", "a-thing"))
|
||||
self.run_pull("500")
|
||||
|
||||
self.assertIn("моя локальная", issue.load(self.root, "a-thing").body)
|
||||
self.assertIn("чужая", issue.load(self.root, "a-thing-2").body)
|
||||
|
||||
def test_the_branch_ref_comes_back_with_the_issue(self):
|
||||
"""`branch:` is not written back to a file that is being deleted; it
|
||||
goes up in the payload and comes down again on the next pull."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.run_pull(str(n))
|
||||
self.assertEqual(issue.load(self.root, "a-thing").extra.get("branch"),
|
||||
"test-branch")
|
||||
|
||||
def test_pushing_the_pulled_copy_back_is_a_no_op_on_the_body(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.run_push()
|
||||
n = self.number_of("a-thing")
|
||||
self.run_pull(str(n))
|
||||
before = self.fake.body_of(n)
|
||||
|
||||
self.run_push("--update", "a-thing")
|
||||
self.assertEqual(self.fake.body_of(n), before)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the ledger
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class StoreListingTest(StoreTestCase):
|
||||
"""The store layout the drop depends on."""
|
||||
|
||||
def test_a_comment_thread_is_not_an_issue(self):
|
||||
"""`<id>.comments.md` sits in the store beside the issue. A slug has no
|
||||
dot in it, so it is not a slug and not a unit of work — otherwise a bare
|
||||
`push.py` files the comment thread as an issue of its own."""
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.write_comments("a-thing")
|
||||
self.assertEqual(issue.all_ids(self.root), ["a-thing"])
|
||||
|
||||
def test_a_bare_push_with_threads_in_the_store_still_works(self):
|
||||
self.write_issue("a-thing", "A thing")
|
||||
self.write_comments("a-thing")
|
||||
self.run_push()
|
||||
self.assertGone("a-thing")
|
||||
|
||||
|
||||
class LedgerTest(StoreTestCase):
|
||||
"""`.remote.json` after the files it used to index are gone."""
|
||||
|
||||
def test_rebuild_keeps_entries_whose_files_no_longer_exist(self):
|
||||
"""It used to reconstruct the map from the files and save the result,
|
||||
which would now silently drop every pushed issue."""
|
||||
_gitea.save_map(self.root, {gmap.remote_key(REPO, 7): "gone-thing"})
|
||||
self.write_issue("here-thing", "Here thing", origin="gitea",
|
||||
extra={"gitea": gmap.remote_key(REPO, 8)})
|
||||
|
||||
got = _gitea.rebuild_map(self.root, issue.load_all(self.root))
|
||||
self.assertEqual(got, {gmap.remote_key(REPO, 7): "gone-thing",
|
||||
gmap.remote_key(REPO, 8): "here-thing"})
|
||||
self.assertEqual(_gitea.load_map(self.root), got)
|
||||
|
||||
def test_a_second_push_reuses_the_ledger_not_the_files(self):
|
||||
"""Two pushes, no pull in between for the blocker: its file is gone, so
|
||||
its number can only come from the ledger — and the link is still made."""
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.run_push("first-thing")
|
||||
self.assertGone("first-thing")
|
||||
|
||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||
depends=["first-thing"])
|
||||
out, err = self.run_push("second-thing")
|
||||
|
||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
|
||||
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
|
||||
self.assertNotIn("local-only", err)
|
||||
|
||||
def test_the_dry_run_resolves_a_dropped_blocker_from_the_ledger(self):
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.run_push("first-thing")
|
||||
first = self.number_of("first-thing")
|
||||
|
||||
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
|
||||
depends=["first-thing"])
|
||||
out, _ = self.run_push("--dry-run", "second-thing")
|
||||
self.assertIn("link -> %s#%d (first-thing)" % (REPO, first), out)
|
||||
|
||||
def test_ledger_keys_prefers_the_current_repo(self):
|
||||
m = {"other/repo#7": "a-thing", "%s#9" % REPO: "a-thing"}
|
||||
self.assertEqual(push.ledger_keys(m, REPO), {"a-thing": "%s#9" % REPO})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Closed issues leave the store, and nothing else does.
|
||||
|
||||
Two halves, and the second one is the one that matters:
|
||||
|
||||
1. **It evicts.** A closed issue whose `origin:` names a tracker is removed from
|
||||
`tmp/issues/` — the issue file and every sidecar under its slug — by one
|
||||
command, and `INDEX.md` is rebuilt so the directory and its table agree.
|
||||
`skills/sync/scripts/evict.py` does the same after refreshing `state:` from
|
||||
Gitea, so an issue closed in the web UI goes without a pull first.
|
||||
|
||||
2. **It evicts nothing else, ever.** `origin: local` is the only copy of the
|
||||
work there is: it stays in every state, including when it is closed and
|
||||
including when it is named on the command line. An open issue stays. A dry
|
||||
run stays. And a tracker call that fails leaves the whole store on disk —
|
||||
every candidate, not just the ones whose answers had not arrived yet.
|
||||
|
||||
A bug in the second half destroys work, so each path is asserted separately and
|
||||
the assertion is always the same — `os.path.isfile`.
|
||||
|
||||
Nothing here touches a network (the sync half stubs `_gitea.api`, and one test
|
||||
stubs `_gitea.subprocess` so a non-zero `tea` is proved end to end) and nothing
|
||||
here touches the developer's store: every test builds its own under
|
||||
`tempfile.TemporaryDirectory()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import evict # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_evict # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
REAL_API = _gitea.api
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [x] сделано
|
||||
"""
|
||||
|
||||
|
||||
class StoreTestCase(unittest.TestCase):
|
||||
"""A temp store, and fixtures for the three kinds of file that live in it."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="tea-evict-")
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
self.numbers = {}
|
||||
|
||||
# -- fixtures ----------------------------------------------------------
|
||||
|
||||
def local(self, id, state="open"):
|
||||
"""An issue that exists nowhere but here."""
|
||||
return self._write(id, state=state, origin=issue.LOCAL)
|
||||
|
||||
def synced(self, id, state="open", number=None):
|
||||
"""A working copy of something the tracker already has."""
|
||||
n = number if number is not None else 100 + len(self.numbers)
|
||||
self.numbers[id] = n
|
||||
return self._write(id, state=state, origin=gmap.ORIGIN,
|
||||
extra={"gitea": gmap.remote_key(REPO, n),
|
||||
"url": "https://git.example/%s/issues/%d" % (REPO, n),
|
||||
"synced": "2026-08-10T00:00:00Z"})
|
||||
|
||||
def _write(self, id, state, origin, extra=None):
|
||||
iss = issue.Issue(id=id, title=id.replace("-", " ").capitalize(),
|
||||
body=BODY, labels=["type/task"], state=state,
|
||||
origin=origin, extra=dict(extra or {}))
|
||||
issue.save(self.root, iss)
|
||||
return iss
|
||||
|
||||
def comments(self, id):
|
||||
p = _gitea.comments_path(self.root, id)
|
||||
with open(p, "w") as f:
|
||||
f.write("## comment 1 — someone — 2026-08-10\n\nтекст\n")
|
||||
return p
|
||||
|
||||
# -- runners -----------------------------------------------------------
|
||||
|
||||
def run_evict(self, *argv):
|
||||
return self._run(issue_evict, "issue_evict.py", argv)
|
||||
|
||||
def run_sync_evict(self, *argv):
|
||||
return self._run(evict, "evict.py", argv)
|
||||
|
||||
def _run(self, mod, name, argv):
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
args = [name, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err):
|
||||
mod.main()
|
||||
return self.out.getvalue(), self.err.getvalue()
|
||||
|
||||
# -- assertions --------------------------------------------------------
|
||||
|
||||
def assertOnDisk(self, id, why=""):
|
||||
self.assertTrue(os.path.isfile(issue.path_of(self.root, id)),
|
||||
"%s.md was deleted%s" % (id, why and " — " + why))
|
||||
|
||||
def assertGone(self, id):
|
||||
self.assertFalse(os.path.isfile(issue.path_of(self.root, id)),
|
||||
"%s.md is still on disk" % id)
|
||||
|
||||
def index(self):
|
||||
with open(os.path.join(self.root, "INDEX.md")) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the domain: what belongs to a slug
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class SlugFilesTest(StoreTestCase):
|
||||
"""`issue.slug_files` — how the domain removes an issue completely without
|
||||
knowing what a comment thread is."""
|
||||
|
||||
def test_the_issue_file_comes_first(self):
|
||||
self.synced("a-thing")
|
||||
p = self.comments("a-thing")
|
||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||
[issue.path_of(self.root, "a-thing"), p])
|
||||
|
||||
def test_an_issue_with_no_sidecars_is_one_file(self):
|
||||
self.synced("a-thing")
|
||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||
[issue.path_of(self.root, "a-thing")])
|
||||
|
||||
def test_a_longer_slug_is_not_a_sidecar(self):
|
||||
"""`a-thing-2` is another issue, not a companion of `a-thing`."""
|
||||
self.synced("a-thing")
|
||||
self.synced("a-thing-2")
|
||||
self.assertEqual(issue.slug_files(self.root, "a-thing"),
|
||||
[issue.path_of(self.root, "a-thing")])
|
||||
|
||||
def test_a_missing_store_is_empty_not_an_error(self):
|
||||
self.assertEqual(issue.slug_files(os.path.join(self.root, "nope"), "x"), [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the domain: it evicts
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class EvictsClosedTest(StoreTestCase):
|
||||
|
||||
def test_a_closed_synced_issue_goes(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
self.run_evict()
|
||||
self.assertGone("old-thing")
|
||||
|
||||
def test_the_comment_thread_goes_with_it(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
p = self.comments("old-thing")
|
||||
self.run_evict()
|
||||
self.assertFalse(os.path.isfile(p), "the thread outlived the issue")
|
||||
|
||||
def test_the_store_of_open_and_closed_keeps_exactly_the_open_and_the_local(self):
|
||||
"""The acceptance criterion, whole: a store of both kinds, one run, and
|
||||
what is left is the open issues and the local ones."""
|
||||
self.synced("open-synced")
|
||||
self.synced("closed-synced", state="closed")
|
||||
self.local("open-local")
|
||||
self.local("closed-local", state="closed")
|
||||
|
||||
self.run_evict()
|
||||
|
||||
self.assertEqual(issue.all_ids(self.root),
|
||||
["closed-local", "open-local", "open-synced"])
|
||||
|
||||
def test_the_output_names_every_file_removed(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
p = self.comments("old-thing")
|
||||
out, _ = self.run_evict()
|
||||
self.assertIn("evicted", out)
|
||||
self.assertIn(issue.path_of(self.root, "old-thing"), out)
|
||||
self.assertIn(p, out)
|
||||
|
||||
def test_the_index_is_rebuilt_to_match_the_directory(self):
|
||||
"""`INDEX.md` and the directory agree afterwards — nothing to fix up."""
|
||||
self.synced("old-thing", state="closed")
|
||||
self.synced("live-thing")
|
||||
self.run_evict()
|
||||
index = self.index()
|
||||
self.assertIn("live-thing", index)
|
||||
self.assertNotIn("old-thing", index)
|
||||
|
||||
def test_only_the_named_issue_is_evicted(self):
|
||||
self.synced("first-old", state="closed")
|
||||
self.synced("second-old", state="closed")
|
||||
self.run_evict("first-old")
|
||||
self.assertGone("first-old")
|
||||
self.assertOnDisk("second-old", "it was not named")
|
||||
|
||||
def test_the_ledger_is_not_pruned(self):
|
||||
"""`.remote.json` is the number -> slug ledger, not an index over the
|
||||
files: an evicted issue is exactly as findable as a pushed one."""
|
||||
self.synced("old-thing", state="closed")
|
||||
key = gmap.remote_key(REPO, self.numbers["old-thing"])
|
||||
_gitea.save_map(self.root, {key: "old-thing"})
|
||||
self.run_evict()
|
||||
self.assertEqual(_gitea.load_map(self.root), {key: "old-thing"})
|
||||
|
||||
|
||||
class ClassifyTest(unittest.TestCase):
|
||||
"""The decision itself, pure. Everything below it deletes a file."""
|
||||
|
||||
def issues(self, **kinds):
|
||||
return {id: issue.Issue(id=id, state=state, origin=origin)
|
||||
for id, (state, origin) in kinds.items()}
|
||||
|
||||
def test_closed_and_synced_is_evicted(self):
|
||||
got = issue_evict.classify(self.issues(a=("closed", "gitea")))
|
||||
self.assertEqual(got, (["a"], [], []))
|
||||
|
||||
def test_closed_and_local_is_protected(self):
|
||||
got = issue_evict.classify(self.issues(a=("closed", issue.LOCAL)))
|
||||
self.assertEqual(got, ([], ["a"], []))
|
||||
|
||||
def test_open_is_left_alone_whatever_its_origin(self):
|
||||
got = issue_evict.classify(self.issues(a=("open", "gitea"),
|
||||
b=("open", issue.LOCAL)))
|
||||
self.assertEqual(got, ([], [], ["a", "b"]))
|
||||
|
||||
def test_naming_a_local_issue_does_not_make_it_evictable(self):
|
||||
got = issue_evict.classify(self.issues(a=("closed", issue.LOCAL)), ["a"])
|
||||
self.assertEqual(got, ([], ["a"], []))
|
||||
|
||||
def test_ids_restrict_the_question(self):
|
||||
got = issue_evict.classify(self.issues(a=("closed", "gitea"),
|
||||
b=("closed", "gitea")), ["b"])
|
||||
self.assertEqual(got, (["b"], [], []))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the domain: it evicts nothing else
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class LocalIsNeverEvictedTest(StoreTestCase):
|
||||
"""The criterion that matters most: `origin: local` IS the work."""
|
||||
|
||||
def test_a_closed_local_issue_stays(self):
|
||||
self.local("closed-local", state="closed")
|
||||
self.run_evict()
|
||||
self.assertOnDisk("closed-local", "origin: local is the only copy")
|
||||
|
||||
def test_a_closed_local_issue_named_explicitly_still_stays(self):
|
||||
self.local("closed-local", state="closed")
|
||||
out, _ = self.run_evict("closed-local")
|
||||
self.assertOnDisk("closed-local", "naming it does not make deleting it safe")
|
||||
self.assertIn("kept", out)
|
||||
|
||||
def test_the_receipt_says_why_it_was_kept(self):
|
||||
self.local("closed-local", state="closed")
|
||||
out, _ = self.run_evict()
|
||||
self.assertIn("origin: local", out)
|
||||
self.assertIn("this file IS the issue", out)
|
||||
|
||||
def test_its_sidecars_stay_too(self):
|
||||
self.local("closed-local", state="closed")
|
||||
p = self.comments("closed-local")
|
||||
self.run_evict()
|
||||
self.assertTrue(os.path.isfile(p))
|
||||
|
||||
|
||||
class DryRunTouchesNothingTest(StoreTestCase):
|
||||
|
||||
def test_nothing_is_deleted(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
p = self.comments("old-thing")
|
||||
self.run_evict("--dry-run")
|
||||
self.assertOnDisk("old-thing", "--dry-run must not delete")
|
||||
self.assertTrue(os.path.isfile(p))
|
||||
|
||||
def test_it_prints_what_would_go(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
p = self.comments("old-thing")
|
||||
out, _ = self.run_evict("--dry-run")
|
||||
self.assertIn("would evict", out)
|
||||
self.assertIn(issue.path_of(self.root, "old-thing"), out)
|
||||
self.assertIn(p, out)
|
||||
self.assertIn("nothing was touched", out)
|
||||
|
||||
def test_the_index_is_not_written(self):
|
||||
"""`INDEX.md` is a write like any other — a dry run makes none."""
|
||||
self.synced("old-thing", state="closed")
|
||||
self.run_evict("--dry-run")
|
||||
self.assertFalse(os.path.isfile(os.path.join(self.root, "INDEX.md")))
|
||||
|
||||
|
||||
class NoOpRunsWriteNothingTest(StoreTestCase):
|
||||
|
||||
def test_a_store_with_nothing_to_evict_is_not_rewritten(self):
|
||||
self.synced("live-thing")
|
||||
out, _ = self.run_evict()
|
||||
self.assertIn("0 issue(s) evicted", out)
|
||||
self.assertFalse(os.path.isfile(os.path.join(self.root, "INDEX.md")))
|
||||
|
||||
def test_an_unknown_id_stops_the_run(self):
|
||||
self.synced("old-thing", state="closed")
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_evict("no-such-thing")
|
||||
self.assertOnDisk("old-thing", "the run stopped before anything went")
|
||||
|
||||
def test_a_missing_store_is_an_error_and_not_a_directory_to_create(self):
|
||||
missing = os.path.join(self.root, "nope")
|
||||
self.out, self.err = io.StringIO(), io.StringIO()
|
||||
argv = ["issue_evict.py", "--out", missing]
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
contextlib.redirect_stdout(self.out), \
|
||||
contextlib.redirect_stderr(self.err), \
|
||||
self.assertRaises(SystemExit):
|
||||
issue_evict.main()
|
||||
self.assertFalse(os.path.isdir(missing))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the bridge: the state comes from the tracker
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from memory. GET on an issue, and nothing else."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.states = {} # number -> "open" | "closed"
|
||||
self.answer_override = {} # number -> whatever it should answer instead
|
||||
self.raise_on = None # number -> exception to raise instead
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None, payload_name=None,
|
||||
out_root=None, allow_fail=False):
|
||||
self.calls.append((method, endpoint))
|
||||
number = int(endpoint.rstrip("/").rsplit("/", 1)[1])
|
||||
if self.raise_on == number:
|
||||
raise OSError("tea: command not found")
|
||||
if number in self.answer_override:
|
||||
return self.answer_override[number]
|
||||
return {"number": number, "state": self.states.get(number, "open"),
|
||||
"title": "Whatever", "body": "текст"}
|
||||
|
||||
|
||||
class SyncEvictTestCase(StoreTestCase):
|
||||
|
||||
def setUp(self):
|
||||
StoreTestCase.setUp(self)
|
||||
self.fake = FakeTracker()
|
||||
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def close_in_gitea(self, id):
|
||||
self.fake.states[self.numbers[id]] = "closed"
|
||||
|
||||
def state_on_disk(self, id):
|
||||
return issue.load(self.root, id).state
|
||||
|
||||
|
||||
class TrackerStateWinsTest(SyncEvictTestCase):
|
||||
|
||||
def test_an_issue_closed_upstream_is_evicted_without_a_pull_first(self):
|
||||
"""The observed workflow, in one command: the file still says `open`."""
|
||||
self.synced("old-thing", state="open")
|
||||
self.close_in_gitea("old-thing")
|
||||
self.run_sync_evict()
|
||||
self.assertGone("old-thing")
|
||||
|
||||
def test_an_issue_still_open_upstream_stays(self):
|
||||
self.synced("live-thing", state="open")
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("live-thing", "Gitea says it is open")
|
||||
|
||||
def test_a_stale_closed_file_is_corrected_and_kept(self):
|
||||
"""Reopened in the web UI: the local `state:` stops lying, and the file
|
||||
is not evicted on the strength of what it used to say."""
|
||||
self.synced("back-thing", state="closed")
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("back-thing", "Gitea says it is open again")
|
||||
self.assertEqual(self.state_on_disk("back-thing"), "open")
|
||||
|
||||
def test_a_local_issue_is_never_asked_about(self):
|
||||
self.local("closed-local", state="closed")
|
||||
out, _ = self.run_sync_evict()
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
self.assertOnDisk("closed-local")
|
||||
|
||||
def test_an_issue_with_no_handle_is_reported_and_kept(self):
|
||||
"""`origin: gitea` and nothing to reach it by: a guess would delete a
|
||||
file nobody can get back."""
|
||||
issue.save(self.root, issue.Issue(id="orphan-thing", title="Orphan thing",
|
||||
body=BODY, labels=["type/task"],
|
||||
state="closed", origin=gmap.ORIGIN))
|
||||
_, err = self.run_sync_evict()
|
||||
self.assertIn("orphan-thing", err)
|
||||
self.assertOnDisk("orphan-thing", "it could not be verified")
|
||||
|
||||
def test_the_index_matches_the_directory_afterwards(self):
|
||||
self.synced("old-thing", state="open")
|
||||
self.synced("live-thing", state="open")
|
||||
self.close_in_gitea("old-thing")
|
||||
self.run_sync_evict()
|
||||
self.assertNotIn("old-thing", self.index())
|
||||
self.assertIn("live-thing", self.index())
|
||||
|
||||
def test_dry_run_asks_but_neither_writes_nor_deletes(self):
|
||||
self.synced("old-thing", state="open")
|
||||
self.close_in_gitea("old-thing")
|
||||
out, _ = self.run_sync_evict("--dry-run")
|
||||
self.assertTrue(self.fake.calls, "it should still have asked")
|
||||
self.assertOnDisk("old-thing", "--dry-run must not delete")
|
||||
self.assertEqual(self.state_on_disk("old-thing"), "open",
|
||||
"--dry-run must not write the refreshed state either")
|
||||
self.assertIn("would evict", out)
|
||||
|
||||
|
||||
class SurvivesEveryTrackerFailureTest(SyncEvictTestCase):
|
||||
"""A failed call evicts nothing — including the candidates whose answers had
|
||||
already arrived."""
|
||||
|
||||
def two_closed(self):
|
||||
self.synced("aaa-thing", state="closed", number=11)
|
||||
self.synced("zzz-thing", state="closed", number=12)
|
||||
self.close_in_gitea("aaa-thing")
|
||||
self.close_in_gitea("zzz-thing")
|
||||
|
||||
def test_a_transport_exception_evicts_nothing(self):
|
||||
self.two_closed()
|
||||
self.fake.raise_on = 12
|
||||
with self.assertRaises(OSError):
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("aaa-thing", "its answer arrived, but the run failed")
|
||||
self.assertOnDisk("zzz-thing")
|
||||
|
||||
def test_a_non_2xx_answer_evicts_nothing(self):
|
||||
"""The real `_gitea.api` against a `tea` that exits 1 — the path a 422
|
||||
or a 500 actually takes, and it ends in `die()`."""
|
||||
self.two_closed()
|
||||
|
||||
def fake_run(cmd, capture_output=False, text=False):
|
||||
return types.SimpleNamespace(returncode=1, stdout="",
|
||||
stderr="500 Internal Server Error")
|
||||
|
||||
with mock.patch.object(_gitea, "api", REAL_API), \
|
||||
mock.patch.object(_gitea, "subprocess",
|
||||
types.SimpleNamespace(run=fake_run)), \
|
||||
self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
|
||||
self.assertOnDisk("aaa-thing", "tea exited non-zero")
|
||||
self.assertOnDisk("zzz-thing", "tea exited non-zero")
|
||||
|
||||
def test_an_answer_for_another_issue_evicts_nothing(self):
|
||||
self.two_closed()
|
||||
self.fake.answer_override[12] = {"number": 999, "state": "closed"}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("aaa-thing")
|
||||
self.assertOnDisk("zzz-thing", "the tracker answered for a different issue")
|
||||
|
||||
def test_an_answer_without_a_state_evicts_nothing(self):
|
||||
self.two_closed()
|
||||
self.fake.answer_override[12] = {"number": 12}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("aaa-thing")
|
||||
self.assertOnDisk("zzz-thing")
|
||||
|
||||
def test_an_empty_answer_evicts_nothing(self):
|
||||
"""`tea` exited 0 and printed nothing — api returns None."""
|
||||
self.two_closed()
|
||||
self.fake.answer_override[12] = None
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertOnDisk("aaa-thing")
|
||||
self.assertOnDisk("zzz-thing")
|
||||
|
||||
def test_the_error_says_nothing_was_evicted(self):
|
||||
self.two_closed()
|
||||
self.fake.answer_override[12] = {"ok": True}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertIn("Nothing was evicted", self.err.getvalue())
|
||||
|
||||
def test_no_state_is_written_back_before_the_failure_either(self):
|
||||
"""The write-back happens after every answer is in, so a run that dies
|
||||
leaves the files exactly as it found them."""
|
||||
self.synced("aaa-thing", state="closed", number=11)
|
||||
self.synced("zzz-thing", state="closed", number=12)
|
||||
self.fake.states[11] = "open" # would be corrected on a good run
|
||||
self.fake.answer_override[12] = {"nope": True}
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_sync_evict()
|
||||
self.assertEqual(self.state_on_disk("aaa-thing"), "closed")
|
||||
|
||||
|
||||
class ConfirmedStateTest(unittest.TestCase):
|
||||
"""The gate itself, in the shape of `push.confirmed_number`."""
|
||||
|
||||
def test_a_matching_answer_is_confirmed(self):
|
||||
self.assertEqual(evict.confirmed_state({"number": 42, "state": "closed"}, 42),
|
||||
"closed")
|
||||
self.assertEqual(evict.confirmed_state({"number": 42, "state": "open"}, 42),
|
||||
"open")
|
||||
|
||||
def test_another_issue_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": 43, "state": "closed"}, 42))
|
||||
|
||||
def test_none_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state(None, 42))
|
||||
|
||||
def test_a_list_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state([{"number": 42, "state": "closed"}], 42))
|
||||
|
||||
def test_a_missing_state_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": 42}, 42))
|
||||
|
||||
def test_an_unknown_state_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": 42, "state": "merged"}, 42))
|
||||
|
||||
def test_a_string_number_is_not(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": "42", "state": "closed"}, 42))
|
||||
|
||||
def test_true_is_not_a_number(self):
|
||||
self.assertIsNone(evict.confirmed_state({"number": True, "state": "closed"}, 1))
|
||||
|
||||
|
||||
class CandidatesTest(StoreTestCase):
|
||||
"""Who the tracker is asked about at all."""
|
||||
|
||||
def test_a_synced_issue_is_asked_about_in_its_own_repo(self):
|
||||
self.synced("a-thing", number=7)
|
||||
checkable, unverifiable = evict.candidates(issue.load_all(self.root))
|
||||
self.assertEqual(checkable, [("a-thing", REPO, 7)])
|
||||
self.assertEqual(unverifiable, [])
|
||||
|
||||
def test_a_local_issue_is_in_neither_list(self):
|
||||
self.local("local-thing", state="closed")
|
||||
self.assertEqual(evict.candidates(issue.load_all(self.root)), ([], []))
|
||||
|
||||
def test_a_handle_that_cannot_be_parsed_is_unverifiable(self):
|
||||
issue.save(self.root, issue.Issue(id="bad-thing", origin=gmap.ORIGIN,
|
||||
extra={"gitea": "not-a-key"}))
|
||||
checkable, unverifiable = evict.candidates(issue.load_all(self.root))
|
||||
self.assertEqual(checkable, [])
|
||||
self.assertEqual([id for id, _ in unverifiable], ["bad-thing"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
What the guard guards: `tea` the command, not `tea` the word.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
The bug these tests hold down: the guard asked whether the string contained
|
||||
`tea` surrounded by whitespace, so in a repository *about* the CLI it blocked
|
||||
prose. An issue title, a commit message quoting a raw call, `grep -rn " tea "`
|
||||
and `echo tea` were all refused, with a message telling the operator to add
|
||||
`--login` to `git commit`. The advice could not be followed — the only way
|
||||
past was to reword the sentence.
|
||||
|
||||
Two lines are held at once here, and neither may move without the other: the
|
||||
four false positives pass, and every shape that really runs the CLI — after
|
||||
`&&`, after a pipe, in a subshell, in a substitution, twice in one line — is
|
||||
still blocked or still rewritten. A test that only proved the first would be
|
||||
satisfied by deleting the guard.
|
||||
|
||||
No network and no `tea` binary: the hook is pure decision-making, so the
|
||||
fixture is a directory with a pin in it and a JSON payload on stdin.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
GUARD = os.path.join(REPO, "hooks", "tea-guard.sh")
|
||||
|
||||
sys.path.insert(0, os.path.join(REPO, "skills", "auth", "scripts"))
|
||||
import pin # noqa: E402
|
||||
|
||||
LOGIN = "fixture/user"
|
||||
|
||||
ALLOW, BLOCK, REWRITE = "allow", "block", "rewrite"
|
||||
|
||||
|
||||
class GuardCase(unittest.TestCase):
|
||||
"""One temp project with one pinned login; the hook run as the harness
|
||||
runs it."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-guard-")
|
||||
self.root = os.path.realpath(self._tmp.name)
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
path = pin.settings_path(self.root)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(json.dumps({"env": {pin.ENV_KEY: LOGIN}}))
|
||||
|
||||
def run_guard(self, cmd):
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None)
|
||||
env[pin.PROJECT_DIR_ENV] = self.root
|
||||
p = subprocess.run([sys.executable, GUARD],
|
||||
input=json.dumps({"tool_input": {"command": cmd},
|
||||
"cwd": self.root}),
|
||||
cwd=self.root, env=env,
|
||||
capture_output=True, text=True)
|
||||
return p
|
||||
|
||||
def verdict(self, cmd):
|
||||
p = self.run_guard(cmd)
|
||||
if p.returncode == 2:
|
||||
return BLOCK, p.stderr
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
if not p.stdout.strip():
|
||||
return ALLOW, ""
|
||||
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
||||
return REWRITE, got
|
||||
|
||||
def assertVerdict(self, cmd, expected):
|
||||
kind, detail = self.verdict(cmd)
|
||||
self.assertEqual(kind, expected,
|
||||
"%r → %s (%s)" % (cmd, kind, detail.strip()))
|
||||
return detail
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the four false positives, verbatim from the report
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestProseAboutTheCliRuns(GuardCase):
|
||||
|
||||
def test_an_issue_title_may_name_the_command(self):
|
||||
self.assertVerdict(
|
||||
'python3 skills/issue/scripts/issue_new.py --type bug '
|
||||
'--title "Warn that tea pulls create needs the repo checkout" '
|
||||
'--label comp/use --severity low', ALLOW)
|
||||
|
||||
def test_a_commit_message_may_quote_a_raw_call(self):
|
||||
self.assertVerdict(
|
||||
"git add -A && git commit -F- <<'EOF'\n"
|
||||
"feat: close issues through a script\n"
|
||||
"\n"
|
||||
"Единственным способом сменить state был сырой вызов\n"
|
||||
"tea api -X PATCH ... repos/OWNER/REPO/issues/N\n"
|
||||
"EOF", ALLOW)
|
||||
|
||||
def test_a_one_line_commit_message_may_too(self):
|
||||
self.assertVerdict('git commit -m "route it through tea api"', ALLOW)
|
||||
|
||||
def test_searching_the_repository_for_the_word(self):
|
||||
for cmd in ('grep -rn " tea " docs/',
|
||||
'grep -rn "tea api" skills/',
|
||||
'echo tea'):
|
||||
self.assertVerdict(cmd, ALLOW)
|
||||
|
||||
def test_the_word_as_a_bare_argument_is_still_an_argument(self):
|
||||
"""`echo tea` was the smallest case in the report; these are the same
|
||||
shape with the word in other argument positions."""
|
||||
for cmd in ('ls tea', 'cat notes/tea', 'python3 x.py tea api'):
|
||||
self.assertVerdict(cmd, ALLOW)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# and the real thing is still guarded
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestRealInvocationsStayGuarded(GuardCase):
|
||||
|
||||
def test_a_bare_call_without_a_login_is_blocked(self):
|
||||
detail = self.assertVerdict("tea issues list", BLOCK)
|
||||
self.assertIn("--login", detail)
|
||||
|
||||
def test_the_placeholder_is_rewritten_to_the_pin(self):
|
||||
got = self.assertVerdict(
|
||||
'tea issues list --login "$GITEA_LOGIN" --state open', REWRITE)
|
||||
self.assertIn(LOGIN, got)
|
||||
self.assertNotIn("GITEA_LOGIN", got)
|
||||
|
||||
def test_a_login_named_by_hand_is_blocked(self):
|
||||
detail = self.assertVerdict("tea issues list --login somebody", BLOCK)
|
||||
self.assertIn("do not name the login", detail)
|
||||
|
||||
def test_another_variable_is_not_the_placeholder(self):
|
||||
self.assertVerdict('tea issues list --login "$OTHER"', BLOCK)
|
||||
|
||||
def test_compound_commands_are_read_segment_by_segment(self):
|
||||
for cmd in ('cd /tmp && tea issues list',
|
||||
'echo x | tea api -X GET repos/x/y',
|
||||
'( tea issues list )',
|
||||
'cd /tmp; tea issues list',
|
||||
'FOO=1 tea issues list',
|
||||
'sudo tea issues list',
|
||||
'xargs tea issues list'):
|
||||
self.assertVerdict(cmd, BLOCK)
|
||||
|
||||
def test_substitutions_are_read_too(self):
|
||||
for cmd in ('echo $(tea whoami)',
|
||||
'x=$(tea whoami)',
|
||||
'echo `tea whoami`'):
|
||||
self.assertVerdict(cmd, BLOCK)
|
||||
|
||||
def test_a_guarded_call_beside_prose_that_mentions_the_word(self):
|
||||
"""The two halves of the bug in one line: the guard must ignore the
|
||||
argument and still catch the call."""
|
||||
self.assertVerdict(
|
||||
'git commit -m "route it through tea api" && tea issues list',
|
||||
BLOCK)
|
||||
|
||||
def test_an_absolute_path_to_the_binary_is_the_binary(self):
|
||||
self.assertVerdict("/usr/local/bin/tea issues list", BLOCK)
|
||||
|
||||
def test_every_call_in_the_line_is_rewritten(self):
|
||||
"""A half-rewritten line leaves the second call with an unset variable
|
||||
and therefore no login at all."""
|
||||
got = self.assertVerdict(
|
||||
'tea issues list --login "$GITEA_LOGIN" && '
|
||||
'tea pulls list --login "$GITEA_LOGIN"', REWRITE)
|
||||
self.assertEqual(got.count(LOGIN), 2)
|
||||
self.assertNotIn("GITEA_LOGIN", got)
|
||||
|
||||
def test_a_second_unguarded_call_is_not_covered_by_the_first(self):
|
||||
self.assertVerdict(
|
||||
'tea issues list --login "$GITEA_LOGIN" && tea pulls list', BLOCK)
|
||||
|
||||
def test_prose_naming_the_whitelisted_form_does_not_launder_a_call(self):
|
||||
"""`tea logins list` is allowed because it uses no identity. Quoting
|
||||
that phrase must not turn the call beside it into a whitelisted one."""
|
||||
self.assertVerdict(
|
||||
'echo "run tea logins list first" && tea issues list', BLOCK)
|
||||
|
||||
|
||||
class TestTheWhitelistStillApplies(GuardCase):
|
||||
|
||||
def test_login_enumeration_needs_no_pin(self):
|
||||
for cmd in ("tea logins list", "tea logins ls",
|
||||
"tea --version", "tea --help"):
|
||||
self.assertVerdict(cmd, ALLOW)
|
||||
|
||||
def test_a_whitelisted_call_next_to_a_guarded_one_does_not_excuse_it(self):
|
||||
self.assertVerdict("tea logins list && tea issues list", BLOCK)
|
||||
|
||||
|
||||
class TestUnparseableLinesFailClosed(GuardCase):
|
||||
"""An unbalanced quote means the shell's reading and ours may differ. The
|
||||
old substring test decides — it over-matches, and over-matching blocks."""
|
||||
|
||||
def test_an_unterminated_quote_around_a_call_still_blocks(self):
|
||||
self.assertVerdict('tea issues list --state "open', BLOCK)
|
||||
|
||||
def test_an_unterminated_quote_with_no_call_is_still_allowed(self):
|
||||
self.assertVerdict('echo "unterminated', ALLOW)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Where the login pin is found, and that a git worktree is not a dead zone.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib unittest, no third-party anything, and not one real network call: every
|
||||
run here is against a throwaway repository with a FAKE `tea` first on PATH.
|
||||
|
||||
The bug: the pin was searched for by walking up from CWD only. A worktree is a
|
||||
*sibling* of the main checkout, and `.claude/settings.local.json` is untracked,
|
||||
so it lives in the main checkout and nowhere else — the whole sync layer died
|
||||
inside any worktree with "no login pinned", while `tea` in the same directory
|
||||
worked, because the tea-guard hook had a second, different copy of the search.
|
||||
|
||||
So these tests hold two lines at once: the pin is reachable from a worktree,
|
||||
and the hook and the scripts get their answer from the same function.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||
HOOKS = os.path.join(REPO, "hooks")
|
||||
|
||||
sys.path.insert(0, AUTH_SCRIPTS)
|
||||
import pin # noqa: E402
|
||||
|
||||
HAVE_GIT = shutil.which("git") is not None
|
||||
|
||||
LOGIN = "fixture/user"
|
||||
ENV_KEY = pin.ENV_KEY
|
||||
|
||||
# A `tea` that answers without a network: an empty list for every GET, a
|
||||
# created object for every write. It records its own argv, which is how a test
|
||||
# reads back the login the call actually ran under.
|
||||
FAKE_TEA = '''#!%s
|
||||
import json, os, sys
|
||||
argv = sys.argv[1:]
|
||||
with open(os.environ["TEA_CALL_LOG"], "a") as f:
|
||||
f.write("\\t".join(argv) + "\\n")
|
||||
sys.stdout.write(json.dumps({"id": 1, "number": 101, "name": "created",
|
||||
"html_url": "https://example.invalid/issues/101",
|
||||
"labels": []})
|
||||
if "-X" in argv else "[]")
|
||||
'''
|
||||
|
||||
ISSUE = """\
|
||||
---
|
||||
id: pinned-work
|
||||
state: open
|
||||
labels: [type/task]
|
||||
assignees: []
|
||||
milestone: none
|
||||
depends: []
|
||||
origin: local
|
||||
---
|
||||
# Pinned work
|
||||
|
||||
## Summary
|
||||
Issue фикстуры, живёт в сторе worktree.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Motivation
|
||||
Нужен, чтобы push.py было что отправить.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
"""
|
||||
|
||||
|
||||
def write(path, text):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
class Worktree(object):
|
||||
"""A repository with a pin, and a linked worktree beside it.
|
||||
|
||||
Beside, not below: `main/` and `worktrees/feature/` are siblings, which is
|
||||
the entire shape of the bug. The pin is written after the clone is
|
||||
committed and is covered by .gitignore, so it exists in the main checkout
|
||||
only — exactly as `/tea:auth` leaves it."""
|
||||
|
||||
def __init__(self, pinned=LOGIN):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-")
|
||||
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
|
||||
# own cwd would otherwise disagree with the path we handed it.
|
||||
self.root = os.path.realpath(self._tmp.name)
|
||||
self.main = os.path.join(self.root, "main")
|
||||
self.tree = os.path.join(self.root, "worktrees", "feature")
|
||||
self.calls = os.path.join(self.root, "calls.txt")
|
||||
|
||||
skip = shutil.ignore_patterns("__pycache__")
|
||||
for layer in ("auth", "issue", "sync"):
|
||||
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
|
||||
os.path.join(self.main, "skills", layer, "scripts"),
|
||||
ignore=skip)
|
||||
shutil.copytree(HOOKS, os.path.join(self.main, "hooks"), ignore=skip)
|
||||
write(os.path.join(self.main, ".gitignore"), "tmp/\n.claude/\n")
|
||||
|
||||
self.bin = os.path.join(self.root, "fakebin")
|
||||
os.makedirs(self.bin)
|
||||
tea = os.path.join(self.bin, "tea")
|
||||
write(tea, FAKE_TEA % sys.executable)
|
||||
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
self.git("init", cwd=self.main)
|
||||
self.git("add", "-A", cwd=self.main)
|
||||
self.git("commit", "-m", "fixture", cwd=self.main)
|
||||
self.git("worktree", "add", "-b", "feature", self.tree, cwd=self.main)
|
||||
|
||||
if pinned:
|
||||
write(os.path.join(self.main, ".claude", "settings.local.json"),
|
||||
json.dumps({"env": {ENV_KEY: pinned}}))
|
||||
|
||||
def cleanup(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def env(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
||||
# The start of the search order, cleared: this fixture is about the
|
||||
# steps *after* it, and the developer's own project must not answer.
|
||||
env.pop(pin.PROJECT_DIR_ENV, None)
|
||||
env["PATH"] = self.bin + os.pathsep + env["PATH"]
|
||||
env["TEA_CALL_LOG"] = self.calls
|
||||
env["HOME"] = self.root # keep the developer's git config out
|
||||
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||
env["GIT_CONFIG_GLOBAL"] = os.devnull
|
||||
return env
|
||||
|
||||
def git(self, *args, **kw):
|
||||
cmd = ["git", "-c", "user.email=fixture@example.invalid",
|
||||
"-c", "user.name=fixture", "-c", "commit.gpgsign=false"] + list(args)
|
||||
p = subprocess.run(cmd, cwd=kw.pop("cwd", self.tree), env=self.env(),
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
raise AssertionError("%s failed:\n%s%s" % (" ".join(cmd), p.stdout, p.stderr))
|
||||
return p.stdout.strip()
|
||||
|
||||
def script(self, layer, name):
|
||||
"""A script as the WORKTREE sees it — the copy the operator would run."""
|
||||
return os.path.join(self.tree, "skills", layer, "scripts", name)
|
||||
|
||||
def run(self, script, *args, **kw):
|
||||
p = subprocess.run([sys.executable, script] + list(args),
|
||||
cwd=kw.pop("cwd", self.tree), env=self.env(),
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout, p.stderr
|
||||
|
||||
def tea_calls(self):
|
||||
if not os.path.isfile(self.calls):
|
||||
return []
|
||||
with open(self.calls) as f:
|
||||
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
|
||||
|
||||
def logins_used(self):
|
||||
return [a[a.index("--login") + 1] for a in self.tea_calls() if "--login" in a]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the search itself
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestSearch(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-unit-")
|
||||
self.root = os.path.realpath(self._tmp.name)
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
|
||||
def path(self, *parts):
|
||||
return os.path.join(self.root, *parts)
|
||||
|
||||
def pin_at(self, root, login=LOGIN):
|
||||
write(os.path.join(root, ".claude", "settings.local.json"),
|
||||
json.dumps({"env": {ENV_KEY: login}}))
|
||||
|
||||
def test_the_parent_chain_is_searched(self):
|
||||
self.pin_at(self.root)
|
||||
os.makedirs(self.path("a", "b"))
|
||||
self.assertEqual(pin.search(self.path("a", "b"))[0], LOGIN)
|
||||
|
||||
def test_no_pin_is_no_pin(self):
|
||||
os.makedirs(self.path("a"))
|
||||
self.assertEqual(pin.search(self.path("a")), (None, None))
|
||||
|
||||
def test_an_unreadable_pin_is_not_a_login(self):
|
||||
write(self.path(".claude", "settings.local.json"), "{ not json")
|
||||
self.assertEqual(pin.search(self.root), (None, None))
|
||||
|
||||
def test_an_empty_pin_is_not_a_login(self):
|
||||
write(self.path(".claude", "settings.local.json"),
|
||||
json.dumps({"env": {ENV_KEY: " "}}))
|
||||
self.assertEqual(pin.search(self.root), (None, None))
|
||||
|
||||
def test_a_git_file_pointing_at_a_worktree_reaches_the_main_checkout(self):
|
||||
"""The hop, built by hand from the two files git writes — no git
|
||||
needed to state what the layout means."""
|
||||
main, tree = self.path("main"), self.path("elsewhere", "feature")
|
||||
gitdir = os.path.join(main, ".git", "worktrees", "feature")
|
||||
os.makedirs(gitdir)
|
||||
os.makedirs(tree)
|
||||
write(os.path.join(gitdir, "commondir"), "../..\n")
|
||||
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
|
||||
self.pin_at(main)
|
||||
|
||||
self.assertEqual(pin.main_worktree(tree), main)
|
||||
login, src = pin.search(tree)
|
||||
self.assertEqual(login, LOGIN)
|
||||
self.assertEqual(src, pin.settings_path(main))
|
||||
|
||||
def test_an_ordinary_clone_is_not_a_worktree(self):
|
||||
os.makedirs(self.path("clone", ".git"))
|
||||
self.assertIsNone(pin.main_worktree(self.path("clone")))
|
||||
|
||||
def test_a_submodule_pointer_is_not_a_worktree(self):
|
||||
"""`.git` is a file there too, but it points into .git/modules/… and
|
||||
the tree it belongs to is already on the parent chain."""
|
||||
sub = self.path("super", "lib")
|
||||
gitdir = self.path("super", ".git", "modules", "lib")
|
||||
os.makedirs(gitdir)
|
||||
os.makedirs(sub)
|
||||
write(os.path.join(sub, ".git"), "gitdir: %s\n" % gitdir)
|
||||
self.assertIsNone(pin.main_worktree(sub))
|
||||
|
||||
def test_the_chain_wins_over_the_hop(self):
|
||||
"""The worktree branch may only find a pin the walk up would have
|
||||
missed entirely — it never overrides a nearer one."""
|
||||
main, tree = self.path("main"), self.path("elsewhere", "feature")
|
||||
gitdir = os.path.join(main, ".git", "worktrees", "feature")
|
||||
os.makedirs(gitdir)
|
||||
os.makedirs(tree)
|
||||
write(os.path.join(gitdir, "commondir"), "../..\n")
|
||||
write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir)
|
||||
self.pin_at(main, "main/login")
|
||||
self.pin_at(tree, "worktree/login")
|
||||
self.assertEqual(pin.search(tree)[0], "worktree/login")
|
||||
|
||||
def test_start_dirs_are_ordered_and_deduplicated(self):
|
||||
with mock.patch.dict(os.environ, {pin.PROJECT_DIR_ENV: self.path("p")}):
|
||||
self.assertEqual(pin.start_dirs(self.path("h")),
|
||||
[self.path("p"), self.path("h"),
|
||||
os.path.abspath(os.getcwd())])
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(pin.start_dirs(), [os.path.abspath(os.getcwd())])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# a script run from a worktree
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(HAVE_GIT, "git is not installed")
|
||||
class TestScriptsInAWorktree(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.wt = Worktree()
|
||||
self.addCleanup(self.wt.cleanup)
|
||||
|
||||
def test_a_sync_script_run_from_the_worktree_finds_the_login(self):
|
||||
"""The acceptance criterion, run for real: cwd inside the worktree,
|
||||
the pin in the main checkout, and the call goes out under it."""
|
||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||
"--repo", "fixture/repo", "--state", "all")
|
||||
self.assertEqual(rc, 0, "remote.py failed:\n%s%s" % (out, err))
|
||||
self.assertNotIn("no login pinned", err)
|
||||
self.assertEqual(self.wt.logins_used(), [LOGIN])
|
||||
|
||||
def test_it_does_not_pin_a_second_login_in_the_worktree(self):
|
||||
"""Nothing here writes a settings file, and the worktree is the last
|
||||
place one should appear: it is deleted with the worktree."""
|
||||
self.wt.run(self.wt.script("sync", "remote.py"), "--repo", "fixture/repo")
|
||||
self.assertFalse(os.path.exists(pin.settings_path(self.wt.tree)),
|
||||
"a second settings.local.json appeared in the worktree")
|
||||
|
||||
def test_with_no_pin_anywhere_it_still_says_so(self):
|
||||
wt = Worktree(pinned=None)
|
||||
self.addCleanup(wt.cleanup)
|
||||
rc, out, err = wt.run(wt.script("sync", "remote.py"), "--repo", "fixture/repo")
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("no login pinned", err)
|
||||
self.assertEqual(wt.logins_used(), [])
|
||||
|
||||
def test_the_scripts_own_directory_is_not_a_pin_source(self):
|
||||
"""Run the worktree's script from a directory that is in no pinned
|
||||
tree. The script sits inside a repository that has a pin — and it must
|
||||
still refuse, because the pin belongs to the project being worked on,
|
||||
not to the installation."""
|
||||
outside = os.path.join(self.wt.root, "outside")
|
||||
os.makedirs(outside)
|
||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||
"--repo", "fixture/repo", cwd=outside)
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("no login pinned", err)
|
||||
|
||||
def test_push_from_a_worktree_sends_the_worktree_branch(self):
|
||||
"""`branch:` -> Gitea `ref`. The workaround this fix removes — run the
|
||||
worktree's scripts with cwd in the main checkout — sent the main
|
||||
checkout's branch, which is the one field `branch:` exists for."""
|
||||
write(os.path.join(self.wt.tree, "tmp", "issues", "pinned-work.md"), ISSUE)
|
||||
rc, out, err = self.wt.run(self.wt.script("sync", "push.py"),
|
||||
"pinned-work", "--repo", "fixture/repo")
|
||||
self.assertEqual(rc, 0, "push.py failed:\n%s%s" % (out, err))
|
||||
self.assertIn("created pinned-work #101", out)
|
||||
|
||||
with open(os.path.join(self.wt.tree, "tmp", "payload",
|
||||
"issue-pinned-work.json")) as f:
|
||||
payload = json.load(f)
|
||||
self.assertEqual(payload.get("ref"), "feature")
|
||||
self.assertEqual(self.wt.git("rev-parse", "--abbrev-ref", "HEAD"), "feature")
|
||||
self.assertNotEqual(
|
||||
self.wt.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.wt.main),
|
||||
"feature", "the fixture's two trees are on the same branch")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# one order, one copy of it
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@unittest.skipUnless(HAVE_GIT, "git is not installed")
|
||||
class TestTheHookAndTheScriptsAgree(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.wt = Worktree()
|
||||
self.addCleanup(self.wt.cleanup)
|
||||
|
||||
def guard(self, cwd):
|
||||
"""The hook, as the harness calls it: payload on stdin, decision on
|
||||
stdout."""
|
||||
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
|
||||
"cwd": cwd}
|
||||
p = subprocess.run([sys.executable, os.path.join(self.wt.tree, "hooks",
|
||||
"tea-guard.sh")],
|
||||
input=json.dumps(payload), cwd=cwd, env=self.wt.env(),
|
||||
capture_output=True, text=True)
|
||||
return p
|
||||
|
||||
def test_the_hook_resolves_the_pin_from_the_worktree_too(self):
|
||||
p = self.guard(self.wt.tree)
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
||||
self.assertIn(LOGIN, got)
|
||||
self.assertNotIn("GITEA_LOGIN", got)
|
||||
|
||||
def test_the_hook_and_a_script_answer_the_same_directory_alike(self):
|
||||
"""The regression that started this: in one directory the hook
|
||||
resolved the login and every script said there was none."""
|
||||
rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"),
|
||||
"--repo", "fixture/repo")
|
||||
self.assertEqual(rc, 0, err)
|
||||
script_login = self.wt.logins_used()[0]
|
||||
hook_login = json.loads(self.guard(self.wt.tree).stdout)[
|
||||
"hookSpecificOutput"]["updatedInput"]["command"].split("--login ")[1].split()[0]
|
||||
self.assertEqual(hook_login, script_login)
|
||||
|
||||
def test_the_hook_still_blocks_when_nothing_is_pinned(self):
|
||||
wt = Worktree(pinned=None)
|
||||
self.addCleanup(wt.cleanup)
|
||||
payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'},
|
||||
"cwd": wt.tree}
|
||||
p = subprocess.run([sys.executable, os.path.join(wt.tree, "hooks", "tea-guard.sh")],
|
||||
input=json.dumps(payload), cwd=wt.tree, env=wt.env(),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(p.returncode, 2)
|
||||
self.assertIn("no login is pinned", p.stderr)
|
||||
|
||||
|
||||
class TestNobodyKeepsASecondCopy(unittest.TestCase):
|
||||
"""Mechanical: the search order is written in pin.py, and the two callers
|
||||
spell neither the path nor the walk."""
|
||||
|
||||
CALLERS = (os.path.join(HOOKS, "tea-guard.sh"),
|
||||
os.path.join(SYNC_SCRIPTS, "_gitea.py"))
|
||||
|
||||
def source(self, path):
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
|
||||
def test_the_path_is_spelled_once(self):
|
||||
self.assertEqual(pin.SETTINGS_PARTS, (".claude", "settings.local.json"))
|
||||
for path in self.CALLERS:
|
||||
body = self.source(path)
|
||||
for literal in ('".claude"', "'.claude'"):
|
||||
self.assertNotIn(literal, body,
|
||||
"%s builds the settings path itself" % path)
|
||||
|
||||
def test_both_callers_go_through_the_module(self):
|
||||
for path in self.CALLERS:
|
||||
self.assertIn("import pin", self.source(path),
|
||||
"%s does not resolve the pin through pin.py" % path)
|
||||
|
||||
def test_the_domain_layer_never_learns_what_a_login_is(self):
|
||||
"""The layer rule, unchanged by this: the identity module is imported
|
||||
by the bridge and by the hook, never by a domain."""
|
||||
for layer in ("issue", "page"):
|
||||
d = os.path.join(REPO, "skills", layer, "scripts")
|
||||
for name in sorted(os.listdir(d)):
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
body = self.source(os.path.join(d, name))
|
||||
for banned in ("import pin", "GITEA_LOGIN", "settings.local.json"):
|
||||
self.assertNotIn(banned, body, "%s/%s: %s" % (layer, name, banned))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Where request bodies land, and that writing one never conjures a store.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib unittest, no third-party anything. The bug these tests pin down:
|
||||
`labels.py --bootstrap` on a fresh checkout left `tmp/issues/.payload/` behind,
|
||||
because the only place `_gitea.api` had to put a request file was whatever root
|
||||
the caller handed it — and the label bootstrap, which touches no issue at all,
|
||||
handed it the issue store. A store materialized as a side effect of an
|
||||
operation that has nothing to do with issues.
|
||||
|
||||
Every run here is against a throwaway repository with a FAKE `tea` first on
|
||||
PATH, so nothing reaches the network and the developer's own store is never in
|
||||
the blast radius.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||
|
||||
sys.path.insert(0, SYNC_SCRIPTS)
|
||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
|
||||
|
||||
# A `tea` that answers without a network: an empty list for every GET (so the
|
||||
# repository looks like it has no labels yet) and a created object for every
|
||||
# write. It also records its own argv, which is how a test can tell that the
|
||||
# payload file the script wrote is the one the call actually referenced.
|
||||
FAKE_TEA = '''#!%s
|
||||
import json, os, sys
|
||||
with open(os.path.join(os.environ["TEA_CALL_LOG"], "calls.txt"), "a") as f:
|
||||
f.write("\\t".join(sys.argv[1:]) + "\\n")
|
||||
sys.stdout.write(json.dumps({"id": 1, "name": "created"})
|
||||
if "-X" in sys.argv else "[]")
|
||||
'''
|
||||
|
||||
|
||||
class FakeRepo(object):
|
||||
"""A self-contained repository with no store and no tmp/ at all."""
|
||||
|
||||
def __init__(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
# realpath: on macOS $TMPDIR is a symlink, and a child reporting its
|
||||
# own cwd would otherwise disagree with the path we handed it.
|
||||
self.root = os.path.realpath(self._tmp.name)
|
||||
|
||||
os.makedirs(os.path.join(self.root, ".git")) # the repo marker
|
||||
skip = shutil.ignore_patterns("__pycache__")
|
||||
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
||||
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
|
||||
# the transport resolves the login pin through skills/auth/scripts
|
||||
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
|
||||
os.makedirs(self.path("sub", "deeper"))
|
||||
|
||||
# the login pin the transport insists on, local to this fixture
|
||||
os.makedirs(self.path(".claude"))
|
||||
with open(self.path(".claude", "settings.local.json"), "w") as f:
|
||||
json.dump({"env": {"GITEA_LOGIN": "fixture/user"}}, f)
|
||||
|
||||
self.bin = self.path("fakebin")
|
||||
os.makedirs(self.bin)
|
||||
tea = os.path.join(self.bin, "tea")
|
||||
with open(tea, "w") as f:
|
||||
f.write(FAKE_TEA % sys.executable)
|
||||
os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
def cleanup(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def path(self, *parts):
|
||||
return os.path.join(self.root, *parts)
|
||||
|
||||
@property
|
||||
def store(self):
|
||||
return self.path("tmp", "issues")
|
||||
|
||||
@property
|
||||
def payloads(self):
|
||||
return self.path("tmp", "payload")
|
||||
|
||||
def script(self, layer, name):
|
||||
return self.path("skills", layer, "scripts", name)
|
||||
|
||||
def run(self, script, *args, **kw):
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
||||
env["PATH"] = self.bin + os.pathsep + env["PATH"]
|
||||
env["TEA_CALL_LOG"] = self.root
|
||||
p = subprocess.run([sys.executable, script] + list(args),
|
||||
cwd=kw.pop("cwd", self.root), env=env,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout, p.stderr
|
||||
|
||||
def calls(self):
|
||||
p = os.path.join(self.root, "calls.txt")
|
||||
if not os.path.isfile(p):
|
||||
return []
|
||||
with open(p) as f:
|
||||
return [line.rstrip("\n").split("\t") for line in f if line.strip()]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# resolution
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestPayloadRoot(unittest.TestCase):
|
||||
|
||||
def test_root_is_absolute_and_repo_anchored(self):
|
||||
self.assertTrue(os.path.isabs(_gitea.PAYLOAD_ROOT), _gitea.PAYLOAD_ROOT)
|
||||
self.assertEqual(_gitea.PAYLOAD_ROOT, os.path.join(REPO, "tmp", "payload"))
|
||||
|
||||
def test_it_is_not_the_issue_store_and_not_inside_one(self):
|
||||
"""The acceptance criterion, as a path fact: a request body is not
|
||||
store content, so it may not live in a store or under one."""
|
||||
self.assertNotEqual(_gitea.PAYLOAD_ROOT, issue.ISSUE_ROOT)
|
||||
self.assertFalse(_gitea.PAYLOAD_ROOT.startswith(issue.ISSUE_ROOT + os.sep))
|
||||
self.assertFalse(issue.ISSUE_ROOT.startswith(_gitea.PAYLOAD_ROOT + os.sep))
|
||||
|
||||
def test_the_name_says_what_it_holds(self):
|
||||
"""Named so the distinction is visible: a top-level directory called
|
||||
`payload`, not a dotdir hiding among an issue's files."""
|
||||
self.assertEqual(os.path.basename(_gitea.PAYLOAD_ROOT), "payload")
|
||||
self.assertFalse(os.path.basename(_gitea.PAYLOAD_ROOT).startswith("."))
|
||||
|
||||
def test_gitignore_covers_it(self):
|
||||
"""The rule is `tmp/` is ignored, not which file says so: this plugin
|
||||
lives under `plugins/` in a marketplace repo, and git reads every
|
||||
.gitignore on the way up. So walk up the same way git does."""
|
||||
ignored = set()
|
||||
d = REPO
|
||||
while True:
|
||||
p = os.path.join(d, ".gitignore")
|
||||
if os.path.isfile(p):
|
||||
with open(p) as f:
|
||||
ignored |= {line.strip() for line in f}
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d or os.path.isdir(os.path.join(d, ".git")):
|
||||
break
|
||||
d = parent
|
||||
self.assertEqual(_gitea.PAYLOAD_PARTS[0], "tmp")
|
||||
self.assertIn("tmp/", ignored,
|
||||
"the payload directory is not covered by .gitignore")
|
||||
|
||||
def test_resolution_is_anchored_on_the_module_not_on_cwd(self):
|
||||
repo = FakeRepo()
|
||||
self.addCleanup(repo.cleanup)
|
||||
self.assertEqual(_gitea.payload_root(repo.path("sub", "deeper")),
|
||||
repo.payloads)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the bug: a label bootstrap that materialized the store
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestLabelsTouchesNoStore(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def bootstrap(self, *args, **kw):
|
||||
rc, out, err = self.repo.run(self.repo.script("sync", "labels.py"),
|
||||
"--repo", "fixture/repo", *args, **kw)
|
||||
self.assertEqual(rc, 0, "labels.py failed:\n%s%s" % (out, err))
|
||||
return out, err
|
||||
|
||||
def test_bootstrap_creates_no_store(self):
|
||||
"""The reproduction from the report, run for real: no tmp/issues, and
|
||||
no complaint about one either."""
|
||||
out, _ = self.bootstrap()
|
||||
self.assertIn("created", out)
|
||||
self.assertFalse(os.path.exists(self.repo.store),
|
||||
"labels.py created the issue store")
|
||||
|
||||
def test_bootstrap_writes_its_payloads_to_the_payload_root(self):
|
||||
self.bootstrap()
|
||||
self.assertTrue(os.path.isdir(self.repo.payloads),
|
||||
"no payload directory: %s" % self.repo.payloads)
|
||||
written = os.listdir(self.repo.payloads)
|
||||
self.assertIn("label-type-bug.json", written)
|
||||
for name in written:
|
||||
self.assertTrue(name.startswith("label-"), name)
|
||||
|
||||
# and the file named on the command line is the one that was written
|
||||
sent = [a[a.index("-d") + 1][1:] for a in self.repo.calls() if "-d" in a]
|
||||
self.assertTrue(sent)
|
||||
for path in sent:
|
||||
self.assertEqual(os.path.dirname(path), self.repo.payloads)
|
||||
self.assertTrue(os.path.isfile(path), path)
|
||||
|
||||
def test_the_payload_is_the_request_body(self):
|
||||
self.bootstrap()
|
||||
with open(os.path.join(self.repo.payloads, "label-type-bug.json")) as f:
|
||||
body = json.load(f)
|
||||
self.assertEqual(body.get("name"), "type/bug")
|
||||
self.assertTrue(body.get("color"))
|
||||
|
||||
def test_a_dry_run_writes_nothing_at_all(self):
|
||||
out, _ = self.bootstrap("--dry-run")
|
||||
self.assertIn("nothing was written", out)
|
||||
self.assertFalse(os.path.exists(self.repo.path("tmp")),
|
||||
"a dry run left something behind in tmp/")
|
||||
|
||||
def test_the_directory_does_not_follow_cwd(self):
|
||||
"""Run from a subdirectory: still one payload root, at the repo root.
|
||||
A cwd-relative directory is how the store ended up with a second copy
|
||||
of itself, and this one is resolved the same way to avoid the same
|
||||
class of bug."""
|
||||
self.bootstrap(cwd=self.repo.path("sub", "deeper"))
|
||||
self.assertTrue(os.path.isdir(self.repo.payloads))
|
||||
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")))
|
||||
self.assertFalse(os.path.exists(self.repo.store))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# one place, every caller
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestOnePlaceForEveryCaller(unittest.TestCase):
|
||||
|
||||
def hits(self, needle, skip_transport=False):
|
||||
"""Every `layer/script.py:line` mentioning `needle`."""
|
||||
out = []
|
||||
d = SYNC_SCRIPTS
|
||||
layer = os.path.basename(os.path.dirname(d))
|
||||
for name in sorted(os.listdir(d)):
|
||||
if not name.endswith(".py") or (skip_transport and name == "_gitea.py"):
|
||||
continue
|
||||
with open(os.path.join(d, name)) as f:
|
||||
for n, line in enumerate(f, 1):
|
||||
if needle in line:
|
||||
out.append("%s/%s:%d" % (layer, name, n))
|
||||
return out
|
||||
|
||||
def test_no_caller_chooses_where_its_payload_goes(self):
|
||||
"""Whatever the answer is, it has to be the same for all of them —
|
||||
payload files scattered across the stores of whichever command wrote
|
||||
them is the state this replaced."""
|
||||
self.assertEqual(self.hits("out_root"), [],
|
||||
"a caller still picks a payload directory of its own")
|
||||
|
||||
def test_only_the_transport_names_the_directory(self):
|
||||
self.assertEqual(self.hits("PAYLOAD", skip_transport=True), [],
|
||||
"the payload directory is named outside the transport")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
A pull returns the unit of work: the issue AND what blocks it.
|
||||
|
||||
`--deps` used to be opt-in, so `pull.py 42` wrote a file with an empty
|
||||
`depends:` and `issue_tree.py` drew it as a root with no blockers. The edge was
|
||||
not lost — it lives in Gitea's native dependency graph — but it was not asked
|
||||
for, and the body cannot supply it: `map.from_api` writes slugs into the
|
||||
`## Depends on` prose and never `#N`. Following the graph is now the default.
|
||||
|
||||
What is asserted here:
|
||||
|
||||
1. **The default fills the graph.** A bare `pull.py <n>` fills `depends:` and
|
||||
pulls the blocker too, down to `--depth`.
|
||||
2. **`--no-deps` is the way out, and it is free.** No `depends:`, no recursion,
|
||||
and not one request beyond the issue itself.
|
||||
3. **`--deps` still works and means nothing.** Calls written against the old
|
||||
default keep running and get what they always got.
|
||||
4. **The cost is one request per stored issue.** The native links are fetched
|
||||
once and used twice — for `depends:` and for the walk. Never twice.
|
||||
5. **Filter mode follows blockers out of the selection, deliberately.** A
|
||||
blocker no filter selected still lands in the store and does not spend
|
||||
`--limit`; a closed one is dropped like any other closed issue, and so is
|
||||
the edge to it. An issue the filter dropped costs no link request at all.
|
||||
|
||||
The transport is stubbed at `_gitea.api`, as the other suites do it, and the
|
||||
stub records every call so "how many requests" is an observation. No network,
|
||||
and no test touches the developer's store: each builds its own in a
|
||||
`tempfile.TemporaryDirectory()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import pull # noqa: E402
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
def payload(number, title, state="open"):
|
||||
return {"number": number, "title": title, "body": BODY, "state": state,
|
||||
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
|
||||
"milestone": None, "ref": "main", "updated_at": "2026-08-10T00:00:00Z",
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"repository": {"full_name": REPO}}
|
||||
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from memory, with a native dependency graph.
|
||||
|
||||
`listed` is what the list endpoint serves — the filter's selection. `extra`
|
||||
exists and is fetchable by number but is in no selection, which is how a
|
||||
blocker outside the filter is modelled. `deps` maps a blocked issue's number
|
||||
to the numbers that block it, the direction `GET …/dependencies` reads.
|
||||
"""
|
||||
|
||||
def __init__(self, listed=(), extra=(), deps=None):
|
||||
self.listed = list(listed)
|
||||
self.issues = {p["number"]: p for p in list(listed) + list(extra)}
|
||||
self.deps = {int(k): list(v) for k, v in (deps or {}).items()}
|
||||
self.calls = [] # (method, path), in request order
|
||||
|
||||
# -- what the tests read off it ----------------------------------------
|
||||
|
||||
def paths(self, suffix):
|
||||
return [p for m, p in self.calls if p.endswith(suffix)]
|
||||
|
||||
def issue_gets(self):
|
||||
"""`GET …/issues/<n>` — one issue fetched by number."""
|
||||
return [p for m, p in self.calls
|
||||
if m == "GET" and p.startswith("%s/issues/" % BASE)
|
||||
and p.rsplit("/", 1)[1].isdigit()]
|
||||
|
||||
# -- the seam ----------------------------------------------------------
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, out_root=None, allow_fail=False):
|
||||
path, _, qs = endpoint.partition("?")
|
||||
q = urllib.parse.parse_qs(qs)
|
||||
self.calls.append((method, path))
|
||||
|
||||
if path == "%s/issues" % BASE and method == "GET":
|
||||
page, per = int(q["page"][0]), int(q["limit"][0])
|
||||
return self.listed[(page - 1) * per:(page - 1) * per + per]
|
||||
|
||||
if path.endswith("/comments"):
|
||||
return []
|
||||
|
||||
if path.endswith("/dependencies") and method == "GET":
|
||||
n = int(path.split("/issues/")[1].split("/")[0])
|
||||
return [self.issues[b] for b in self.deps.get(n, []) if b in self.issues]
|
||||
|
||||
if path.startswith("%s/issues/" % BASE) and method == "GET":
|
||||
return self.issues.get(int(path.rsplit("/", 1)[1]))
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class PullDepsTestCase(unittest.TestCase):
|
||||
"""A temp store, a fake tracker, no git and no network."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="tea-deps-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = os.path.join(self.tmp.name, "tmp", "issues")
|
||||
os.makedirs(self.root)
|
||||
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def serve(self, listed=(), extra=(), deps=None):
|
||||
self.fake = FakeTracker(listed, extra, deps)
|
||||
p = mock.patch.object(_gitea, "api", self.fake.api)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return self.fake
|
||||
|
||||
def blocked_pair(self):
|
||||
"""#10 "Second thing" is blocked by #7 "First thing"."""
|
||||
return self.serve(listed=[payload(10, "Second thing"),
|
||||
payload(7, "First thing")],
|
||||
deps={10: [7]})
|
||||
|
||||
def run_pull(self, *argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(err):
|
||||
pull.main()
|
||||
return out.getvalue(), err.getvalue()
|
||||
|
||||
def stored(self):
|
||||
return sorted(issue.all_ids(self.root))
|
||||
|
||||
def depends_of(self, id):
|
||||
return issue.load(self.root, id).depends
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. the default fills the graph
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class DepsAreTheDefaultTest(PullDepsTestCase):
|
||||
|
||||
def test_a_bare_pull_fills_depends(self):
|
||||
"""The acceptance criterion, and the whole point: no flag, and the file
|
||||
knows what blocks it."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10")
|
||||
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
|
||||
|
||||
def test_a_bare_pull_stores_the_blocker(self):
|
||||
"""`depends:` pointing at a file that is not there would be worse than
|
||||
an empty one — the blocker comes with it."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10")
|
||||
self.assertIn("first-thing", self.stored())
|
||||
|
||||
def test_the_walk_is_recursive(self):
|
||||
"""A blocker's blocker is context too, down to --depth (default 3)."""
|
||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
|
||||
deps={1: [2], 2: [3], 3: [4], 4: [5]})
|
||||
self.run_pull("1")
|
||||
self.assertEqual(self.stored(), ["thing-1", "thing-2", "thing-3", "thing-4"],
|
||||
"the default depth of 3 was not what was walked")
|
||||
|
||||
def test_depth_bounds_the_walk(self):
|
||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 6)],
|
||||
deps={1: [2], 2: [3], 3: [4], 4: [5]})
|
||||
self.run_pull("1", "--depth", "1")
|
||||
self.assertEqual(self.stored(), ["thing-1", "thing-2"])
|
||||
|
||||
def test_the_graph_hint_is_printed_when_there_is_a_graph(self):
|
||||
self.blocked_pair()
|
||||
out, _ = self.run_pull("10")
|
||||
self.assertIn("issue_tree.py", out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. --no-deps is the way out, and it is free
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class NoDepsOptsOutTest(PullDepsTestCase):
|
||||
|
||||
def test_no_deps_leaves_depends_empty(self):
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--no-deps")
|
||||
self.assertEqual(self.depends_of("second-thing"), [])
|
||||
|
||||
def test_no_deps_does_not_pull_the_blocker(self):
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--no-deps")
|
||||
self.assertEqual(self.stored(), ["second-thing"])
|
||||
|
||||
def test_no_deps_spends_no_extra_request(self):
|
||||
"""The other half of the criterion: not the links, not the blocker.
|
||||
One issue asked for, one request made."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--no-deps")
|
||||
self.assertEqual(self.fake.paths("/dependencies"), [])
|
||||
self.assertEqual(self.fake.issue_gets(), ["%s/issues/10" % BASE])
|
||||
|
||||
def test_no_deps_prints_no_graph_hint(self):
|
||||
self.blocked_pair()
|
||||
out, _ = self.run_pull("10", "--no-deps")
|
||||
self.assertNotIn("issue_tree.py", out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. --deps is still accepted, and means nothing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class DepsFlagIsANoOpTest(PullDepsTestCase):
|
||||
|
||||
def test_the_flag_is_still_accepted(self):
|
||||
"""Existing calls and the /tea:sync command tables must not break."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--deps")
|
||||
self.assertEqual(self.depends_of("second-thing"), ["first-thing"])
|
||||
|
||||
def test_it_changes_nothing_about_the_run(self):
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--deps")
|
||||
with_flag = (self.stored(), self.depends_of("second-thing"),
|
||||
list(self.fake.calls))
|
||||
|
||||
self.setUp()
|
||||
self.blocked_pair()
|
||||
self.run_pull("10")
|
||||
self.assertEqual((self.stored(), self.depends_of("second-thing"),
|
||||
list(self.fake.calls)), with_flag)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. one request per stored issue
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TheCostIsOneRequestPerIssueTest(PullDepsTestCase):
|
||||
|
||||
def test_the_links_are_fetched_once_per_issue(self):
|
||||
"""They fill `depends:` AND steer the walk; fetching them twice is
|
||||
double the price the docstring quotes."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10")
|
||||
self.assertEqual(self.fake.paths("/dependencies"),
|
||||
["%s/issues/10/dependencies" % BASE,
|
||||
"%s/issues/7/dependencies" % BASE])
|
||||
|
||||
def test_a_bulk_pull_costs_one_per_issue(self):
|
||||
"""The number the docstring quotes: one list request, then one link
|
||||
request per issue that lands in the store."""
|
||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 21)])
|
||||
self.run_pull("-q", "x")
|
||||
self.assertEqual(len(self.fake.paths("/dependencies")), 20)
|
||||
self.assertEqual(len(self.fake.paths("/issues")), 1)
|
||||
|
||||
def test_a_cached_issue_costs_its_links_and_nothing_else(self):
|
||||
"""--cached stops the body and the thread, not the graph: a cached
|
||||
issue's blockers can be missing from disk even when it is not."""
|
||||
self.blocked_pair()
|
||||
self.run_pull("10", "--no-deps") # only #10 on disk
|
||||
self.fake.calls = []
|
||||
self.run_pull("10", "--cached")
|
||||
self.assertEqual(self.fake.paths("/dependencies"),
|
||||
["%s/issues/10/dependencies" % BASE,
|
||||
"%s/issues/7/dependencies" % BASE])
|
||||
self.assertIn("first-thing", self.stored())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. filter mode follows blockers out of the selection
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class FilterModeFollowsOutwardTest(PullDepsTestCase):
|
||||
|
||||
def test_a_blocker_outside_the_filter_lands_in_the_store(self):
|
||||
"""Documented as deliberate: a blocker is followed because a stored
|
||||
issue named it, not because the filter selected it."""
|
||||
self.serve(listed=[payload(1, "Selected thing")],
|
||||
extra=[payload(99, "Outside thing")],
|
||||
deps={1: [99]})
|
||||
self.run_pull("-q", "x")
|
||||
self.assertEqual(self.stored(), ["outside-thing", "selected-thing"])
|
||||
self.assertEqual(self.depends_of("selected-thing"), ["outside-thing"])
|
||||
|
||||
def test_a_blocker_does_not_spend_the_limit(self):
|
||||
"""--limit counts the selection's writes; the graph is not part of the
|
||||
selection, so the store can legitimately hold more than N."""
|
||||
self.serve(listed=[payload(n, "Thing %d" % n) for n in range(1, 5)],
|
||||
extra=[payload(100 + n, "Blocker %d" % n) for n in range(1, 5)],
|
||||
deps={n: [100 + n] for n in range(1, 5)})
|
||||
self.run_pull("-q", "x", "--limit", "2")
|
||||
self.assertEqual(self.stored(),
|
||||
["blocker-1", "blocker-2", "thing-1", "thing-2"])
|
||||
|
||||
def test_a_closed_blocker_is_dropped_with_the_edge_to_it(self):
|
||||
"""The documented exception. Closed is not a unit of work, so filter
|
||||
mode drops it like any other closed issue — and `depends:` must not be
|
||||
left pointing at a file that is not there."""
|
||||
self.serve(listed=[payload(1, "Selected thing")],
|
||||
extra=[payload(99, "Closed blocker", state="closed")],
|
||||
deps={1: [99]})
|
||||
self.run_pull("-q", "x")
|
||||
self.assertEqual(self.stored(), ["selected-thing"])
|
||||
self.assertEqual(self.depends_of("selected-thing"), [])
|
||||
|
||||
def test_a_closed_blocker_is_stored_in_key_mode(self):
|
||||
"""An address is not a bulk read: `pull.py 1` has no closed rule."""
|
||||
self.serve(listed=[payload(1, "Selected thing")],
|
||||
extra=[payload(99, "Closed blocker", state="closed")],
|
||||
deps={1: [99]})
|
||||
self.run_pull("1")
|
||||
self.assertEqual(self.stored(), ["closed-blocker", "selected-thing"])
|
||||
|
||||
def test_a_dropped_closed_issue_costs_no_link_request(self):
|
||||
"""Nothing was stored for it, so there is no unit of work to complete
|
||||
— and its own blockers are not dragged in behind it."""
|
||||
self.serve(listed=[payload(1, "Closed thing", state="closed"),
|
||||
payload(2, "Open thing")],
|
||||
extra=[payload(50, "Blocker of the closed one")],
|
||||
deps={1: [50]})
|
||||
self.run_pull("-q", "x", "--state", "all")
|
||||
self.assertEqual(self.fake.paths("/dependencies"),
|
||||
["%s/issues/2/dependencies" % BASE])
|
||||
self.assertEqual(self.stored(), ["open-thing"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
`pull.py --limit N` bounds the WRITE, not the selection.
|
||||
|
||||
The bug this file exists to keep dead: the limit used to cut the list of
|
||||
payloads before pull.py dropped the closed ones, so a milestone whose first
|
||||
issues are closed spent the budget on issues that never reached disk —
|
||||
`--limit 20` wrote twelve, and the docstring promised twenty.
|
||||
|
||||
What is asserted, in the order the fix has to hold it:
|
||||
|
||||
1. **The count is of files.** N issues under the filter that would be stored →
|
||||
exactly N files, however many closed ones were enumerated on the way.
|
||||
2. **Pagination serves the budget.** More pages are requested while the budget
|
||||
is unfilled, and the page after the one that fills it is never requested.
|
||||
3. **The scan is bounded.** A filter that matches almost only closed issues
|
||||
stops after `_gitea.PAGE_SLACK` times the ideal page count, says so, and
|
||||
returns short — it does not walk the tracker.
|
||||
4. **`remote.py` is unchanged.** Its `--limit` still caps the listing, closed
|
||||
issues included, because it writes nothing there is a limit for.
|
||||
|
||||
The transport is stubbed at `_gitea.api`, the way the other suites do it, and
|
||||
the stub serves `page=` / `limit=` itself so the request pattern is a real
|
||||
observation and not an assumption. No network, and no test writes to the
|
||||
developer's store: each one builds its own in a `tempfile.TemporaryDirectory()`.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
import pull # noqa: E402
|
||||
import remote # noqa: E402
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание задачи.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
def payload(number, state="open", title=None, comments=0):
|
||||
return {"number": number, "title": title or "Issue number %d" % number,
|
||||
"body": BODY, "state": state, "comments": comments,
|
||||
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
||||
"ref": "main", "updated_at": "2026-08-10T00:00:00Z",
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"repository": {"full_name": REPO}}
|
||||
|
||||
|
||||
def alternating(count, first="closed"):
|
||||
"""`count` issues, every other one closed. The shape of the bug report:
|
||||
closed issues sitting in front of the open ones, in page order."""
|
||||
other = "open" if first == "closed" else "closed"
|
||||
return [payload(n, first if n % 2 else other) for n in range(1, count + 1)]
|
||||
|
||||
|
||||
class FakeTracker(object):
|
||||
"""`tea api` answered from a list, with real pagination.
|
||||
|
||||
It slices on the `page=` and `limit=` it was given rather than ignoring
|
||||
them, so "which pages were requested" is something the test can read off
|
||||
`self.list_pages` instead of inferring."""
|
||||
|
||||
def __init__(self, payloads):
|
||||
self.payloads = list(payloads)
|
||||
self.list_pages = [] # (page, per_page), in request order
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, out_root=None, allow_fail=False):
|
||||
path, _, qs = endpoint.partition("?")
|
||||
q = urllib.parse.parse_qs(qs)
|
||||
|
||||
if path == "%s/issues" % BASE and method == "GET":
|
||||
page, per = int(q["page"][0]), int(q["limit"][0])
|
||||
self.list_pages.append((page, per))
|
||||
return self.payloads[(page - 1) * per:(page - 1) * per + per]
|
||||
|
||||
if path.endswith("/comments"):
|
||||
return []
|
||||
|
||||
if path.endswith("/dependencies"):
|
||||
return []
|
||||
|
||||
if "/issues/" in path and method == "GET":
|
||||
n = int(path.rsplit("/", 1)[1])
|
||||
for p in self.payloads:
|
||||
if p["number"] == n:
|
||||
return p
|
||||
return None
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class PullLimitTestCase(unittest.TestCase):
|
||||
"""A temp store, a fake tracker, no git and no network."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="tea-limit-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = os.path.join(self.tmp.name, "tmp", "issues")
|
||||
os.makedirs(self.root)
|
||||
p = mock.patch.object(_gitea, "require_login", lambda: "test-login")
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
# -- runners -----------------------------------------------------------
|
||||
|
||||
def serve(self, payloads):
|
||||
self.fake = FakeTracker(payloads)
|
||||
p = mock.patch.object(_gitea, "api", self.fake.api)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return self.fake
|
||||
|
||||
def run_pull(self, *argv):
|
||||
return self._run(pull, "pull.py", argv)
|
||||
|
||||
def run_remote(self, *argv):
|
||||
return self._run(remote, "remote.py", argv)
|
||||
|
||||
def _run(self, mod, name, argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(err):
|
||||
mod.main()
|
||||
return out.getvalue(), err.getvalue()
|
||||
|
||||
# -- assertions --------------------------------------------------------
|
||||
|
||||
def stored(self):
|
||||
return sorted(issue.all_ids(self.root))
|
||||
|
||||
def assertStoredCount(self, n, why=""):
|
||||
got = self.stored()
|
||||
self.assertEqual(len(got), n, "%d issue(s) in the store, wanted %d%s: %s"
|
||||
% (len(got), n, why and " — " + why, got))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. the count is of files
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class LimitCountsWritesTest(PullLimitTestCase):
|
||||
|
||||
def test_closed_issues_do_not_spend_the_budget(self):
|
||||
"""The regression. Half the selection is closed and stands in front of
|
||||
the open ones; the limit still buys ten files."""
|
||||
self.serve(alternating(40))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(10)
|
||||
|
||||
def test_only_open_issues_landed(self):
|
||||
self.serve(alternating(40))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
for id in self.stored():
|
||||
self.assertEqual(issue.load(self.root, id).state, "open")
|
||||
|
||||
def test_the_dropped_ones_are_still_reported(self):
|
||||
"""Enumerated-and-dropped is not silence: the closed ones seen on the
|
||||
pages that were fetched are counted on stderr."""
|
||||
self.serve(alternating(40))
|
||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertIn("closed issue(s) enumerated, not stored", err)
|
||||
|
||||
def test_a_closed_issue_already_in_the_store_spends_it(self):
|
||||
"""It is refreshed rather than dropped — that is a write, so it counts.
|
||||
The limit is on what the store holds when the run ends, and this issue
|
||||
is in it."""
|
||||
kept = issue.Issue(id="already-here", title="Already here", body=BODY,
|
||||
labels=["type/task"], origin="gitea",
|
||||
extra={"gitea": gmap.remote_key(REPO, 1)})
|
||||
issue.save(self.root, kept)
|
||||
_gitea.save_map(self.root, {gmap.remote_key(REPO, 1): "already-here"})
|
||||
|
||||
self.serve(alternating(40)) # #1 is closed, and is on disk
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(10)
|
||||
self.assertEqual(issue.load(self.root, "already-here").state, "closed",
|
||||
"a stored issue must learn it was closed")
|
||||
|
||||
def test_state_closed_writes_closed_ones(self):
|
||||
"""Nothing above may leak into the mode where closed IS the selection."""
|
||||
self.serve([payload(n, "closed") for n in range(1, 21)])
|
||||
self.run_pull("-q", "x", "--state", "closed", "--limit", "6")
|
||||
self.assertStoredCount(6)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. pagination serves the budget
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class PaginationFollowsTheBudgetTest(PullLimitTestCase):
|
||||
|
||||
def test_more_pages_are_fetched_until_the_budget_is_full(self):
|
||||
"""One page of ten holds five open issues, so ten files cost two."""
|
||||
self.serve(alternating(40))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(10)
|
||||
self.assertEqual([p for p, _ in self.fake.list_pages], [1, 2])
|
||||
|
||||
def test_the_page_after_the_last_needed_one_is_never_requested(self):
|
||||
"""The budget fills inside page 2; page 3 exists and must not be asked
|
||||
for. Bounding the write must not become fetching the whole repo."""
|
||||
self.serve(alternating(200))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertEqual(len(self.fake.list_pages), 2,
|
||||
"extra pages requested: %r" % (self.fake.list_pages,))
|
||||
|
||||
def test_an_unfiltered_selection_still_costs_one_page(self):
|
||||
"""Nothing is dropped, so nothing changes: the old arithmetic holds."""
|
||||
self.serve([payload(n) for n in range(1, 60)])
|
||||
self.run_pull("-q", "x", "--limit", "10")
|
||||
self.assertStoredCount(10)
|
||||
self.assertEqual(len(self.fake.list_pages), 1)
|
||||
|
||||
def test_running_out_of_pages_gives_a_short_answer(self):
|
||||
"""Six issues, three of them open, `--limit 10`: three files, no crash,
|
||||
and no page beyond the last."""
|
||||
self.serve(alternating(6))
|
||||
self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(3)
|
||||
self.assertEqual(len(self.fake.list_pages), 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. the scan is bounded
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ScanIsBoundedTest(PullLimitTestCase):
|
||||
|
||||
def test_a_selection_of_only_closed_issues_stops_at_the_page_budget(self):
|
||||
self.serve([payload(n, "closed") for n in range(1, 501)])
|
||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(0)
|
||||
self.assertEqual(len(self.fake.list_pages), _gitea.PAGE_SLACK,
|
||||
"the scan walked past its budget: %r" % (self.fake.list_pages,))
|
||||
self.assertIn("short of --limit", err)
|
||||
|
||||
def test_a_full_budget_does_not_warn(self):
|
||||
"""The warning means "there may be more"; it must not fire on a run
|
||||
that got everything it asked for."""
|
||||
self.serve(alternating(40))
|
||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertNotIn("short of --limit", err)
|
||||
|
||||
def test_a_selection_that_ran_out_does_not_warn(self):
|
||||
"""Six issues in the repo and the server said so — that is an answer,
|
||||
not a truncation."""
|
||||
self.serve(alternating(6))
|
||||
_, err = self.run_pull("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertNotIn("short of --limit", err)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. remote.py is the deliberate exception
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class RemoteListingIsUnchangedTest(PullLimitTestCase):
|
||||
|
||||
def test_the_listing_limit_still_counts_lines_not_writes(self):
|
||||
"""remote.py writes nothing, so there is no write to bound: ten lines
|
||||
out, closed ones among them, one request."""
|
||||
self.serve(alternating(40))
|
||||
out, _ = self.run_remote("-q", "x", "--state", "all", "--limit", "10")
|
||||
numbered = [l for l in out.splitlines() if l.startswith("#")]
|
||||
self.assertEqual(len(numbered), 10)
|
||||
self.assertTrue(any("closed" in l for l in numbered),
|
||||
"a listing that hides closed issues is not a listing")
|
||||
self.assertEqual(len(self.fake.list_pages), 1)
|
||||
|
||||
def test_it_leaves_the_store_alone(self):
|
||||
self.serve(alternating(40))
|
||||
self.run_remote("-q", "x", "--state", "all", "--limit", "10")
|
||||
self.assertStoredCount(0, "discovery wrote to the store")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the transport on its own
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ListIssuesKeepTest(PullLimitTestCase):
|
||||
"""`_gitea.list_issues` without a caller in front of it — the counting rule
|
||||
is the transport's, and it is testable without a store."""
|
||||
|
||||
def list(self, payloads, **kw):
|
||||
self.serve(payloads)
|
||||
return _gitea.list_issues("test-login", BASE, state="all", **kw)
|
||||
|
||||
def test_without_keep_the_limit_caps_the_selection(self):
|
||||
got, _ = self.list(alternating(40), limit=10)
|
||||
self.assertEqual(len(got), 10)
|
||||
|
||||
def test_with_keep_the_limit_caps_the_kept(self):
|
||||
got, _ = self.list(alternating(40), limit=10,
|
||||
keep=lambda p: p["state"] == "open")
|
||||
self.assertEqual(len([p for p in got if p["state"] == "open"]), 10)
|
||||
|
||||
def test_the_rejected_ones_come_back_too(self):
|
||||
"""They were enumerated. The caller reports them; the transport does
|
||||
not get to throw away what it did not count."""
|
||||
got, _ = self.list(alternating(40), limit=10,
|
||||
keep=lambda p: p["state"] == "open")
|
||||
self.assertTrue([p for p in got if p["state"] == "closed"])
|
||||
|
||||
def test_a_limit_below_one_is_refused(self):
|
||||
"""The page arithmetic divides by the page size, and a limit of zero
|
||||
used to make that a traceback. It is a usage error, so it reads like
|
||||
one."""
|
||||
with self.assertRaises(SystemExit):
|
||||
self.list(alternating(4), limit=0)
|
||||
|
||||
def test_pull_requests_never_count(self):
|
||||
"""`matches` drops them, so they cannot spend the budget either."""
|
||||
mixed = []
|
||||
for n in range(1, 41):
|
||||
p = payload(n)
|
||||
if n % 2:
|
||||
p["pull_request"] = {"merged": False}
|
||||
mixed.append(p)
|
||||
got, _ = self.list(mixed, limit=10, keep=lambda p: True)
|
||||
self.assertEqual(len(got), 10)
|
||||
self.assertFalse([p for p in got if p.get("pull_request")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Native Gitea dependency links, written by push.py.
|
||||
|
||||
The transport is stubbed at exactly one seam — `_gitea.api`, the single
|
||||
function that shells out to `tea` — so everything above it runs for real:
|
||||
argument parsing, validation, topological order, the id map, map.py's payload
|
||||
shapes and _gitea's own endpoint/body construction. Nothing here touches a
|
||||
network, and no test may ever be made to.
|
||||
|
||||
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
import push # noqa: E402
|
||||
|
||||
REPO = "claude-skills/tea"
|
||||
BASE = "repos/%s" % REPO
|
||||
LABELS = {"type/task": 901, "type/bug": 902, "severity/medium": 903,
|
||||
"comp/sync": 904}
|
||||
LABEL_NAMES = {v: k for k, v in LABELS.items()}
|
||||
|
||||
BODY = """## Summary
|
||||
Прозаическое описание.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Depends on
|
||||
- first-thing — ставит фундамент, без него второй не собрать
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
BODY_NO_DEPS = """## Summary
|
||||
Прозаическое описание.
|
||||
|
||||
## Spec
|
||||
skills/issue/references/format.md
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] что-нибудь работает
|
||||
"""
|
||||
|
||||
|
||||
class FakeGitea(object):
|
||||
"""A `tea api` that answers from memory and remembers what it was asked.
|
||||
|
||||
Dependency links are kept the way Gitea keeps them: per blocked issue, a
|
||||
set of (repo, number) blockers. That is what makes the idempotence test
|
||||
meaningful — the second push sees the link the first one made."""
|
||||
|
||||
def __init__(self, next_number=101):
|
||||
self.calls = [] # (method, endpoint, payload)
|
||||
self.next_number = next_number
|
||||
self.deps = {} # number -> {(repo, number)}
|
||||
self.titles = {} # number -> title
|
||||
self.fail_dependency_post = False
|
||||
|
||||
# -- helpers -----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def writes(self):
|
||||
"""Every non-GET call. `--dry-run` must produce an empty list."""
|
||||
return [c for c in self.calls if c[0] != "GET"]
|
||||
|
||||
def dep_posts(self):
|
||||
return [c for c in self.calls
|
||||
if c[0] == "POST" and c[1].endswith("/dependencies")]
|
||||
|
||||
def issue_payload(self, number, labels=()):
|
||||
return {"number": number,
|
||||
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||
"title": self.titles.get(number, ""),
|
||||
"labels": [{"name": LABEL_NAMES[i]} for i in labels
|
||||
if i in LABEL_NAMES],
|
||||
"updated_at": "2026-08-10T00:00:00Z",
|
||||
"repository": {"full_name": REPO}}
|
||||
|
||||
# -- the seam ----------------------------------------------------------
|
||||
|
||||
def api(self, login, endpoint, method="GET", payload=None,
|
||||
payload_name=None, allow_fail=False):
|
||||
self.calls.append((method, endpoint, payload))
|
||||
path = endpoint.split("?")[0]
|
||||
|
||||
if path == "%s/labels" % BASE and method == "GET":
|
||||
# Every label the run could ask for, so nothing is ever created.
|
||||
return [{"name": n, "id": i} for n, i in LABELS.items()]
|
||||
|
||||
if path == "%s/issues" % BASE and method == "POST":
|
||||
number = self.next_number
|
||||
self.next_number += 1
|
||||
self.titles[number] = (payload or {}).get("title", "")
|
||||
# Echo the labels back, or push re-applies them with a PUT.
|
||||
return self.issue_payload(number, (payload or {}).get("labels") or [])
|
||||
|
||||
if path.endswith("/dependencies"):
|
||||
number = int(path.split("/issues/")[1].split("/")[0])
|
||||
if method == "GET":
|
||||
return [dict(self.issue_payload(n), repository={"full_name": r})
|
||||
for r, n in sorted(self.deps.get(number, set()))]
|
||||
if method == "POST":
|
||||
if self.fail_dependency_post:
|
||||
return None
|
||||
key = ("%s/%s" % (payload["owner"], payload["repo"]),
|
||||
int(payload["index"]))
|
||||
self.deps.setdefault(number, set()).add(key)
|
||||
return self.issue_payload(number)
|
||||
|
||||
if "/issues/" in path and method == "PATCH":
|
||||
number = int(path.rsplit("/", 1)[1])
|
||||
self.titles[number] = (payload or {}).get("title", self.titles.get(number, ""))
|
||||
return self.issue_payload(number, (payload or {}).get("labels") or [])
|
||||
|
||||
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||
|
||||
|
||||
class PushTestCase(unittest.TestCase):
|
||||
"""A temp store, a fake transport, and no git."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
||||
self.fake = FakeGitea()
|
||||
patches = [
|
||||
mock.patch.object(_gitea, "api", self.fake.api),
|
||||
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
|
||||
# push reads the current branch from git; a temp store has none and
|
||||
# the runner's branch would leak into the payload.
|
||||
mock.patch.object(push, "git_branch", lambda: "test-branch"),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
|
||||
# -- fixtures ----------------------------------------------------------
|
||||
|
||||
def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None,
|
||||
origin=issue.LOCAL):
|
||||
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
|
||||
depends=list(depends), origin=origin,
|
||||
extra=dict(extra or {}))
|
||||
issue.save(self.root, iss)
|
||||
return iss
|
||||
|
||||
def repull(self, id, body=BODY_NO_DEPS, depends=()):
|
||||
"""Put a pushed issue back the way `pull.py` would.
|
||||
|
||||
Push deletes the file, so anything that pushes the same issue twice has
|
||||
to fetch it in between — which is the workflow, not a test artifact.
|
||||
The slug and the number come from the ledger, exactly as `pull.id_for`
|
||||
would resolve them."""
|
||||
number = self.number_of(id)
|
||||
self.assertIsNotNone(number, "%s was never pushed" % id)
|
||||
return self.write_issue(id, self.fake.titles[number], body=body,
|
||||
depends=depends, origin="gitea",
|
||||
extra={"gitea": "%s#%d" % (REPO, number)})
|
||||
|
||||
def two_issues(self):
|
||||
"""first-thing, and second-thing which depends on it."""
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.write_issue("second-thing", "Second thing", body=BODY,
|
||||
depends=["first-thing"])
|
||||
|
||||
def run_push(self, *argv):
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
args = ["push.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||
with mock.patch.object(sys, "argv", args), \
|
||||
contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(err):
|
||||
push.main()
|
||||
return out.getvalue(), err.getvalue()
|
||||
|
||||
def number_of(self, id):
|
||||
"""The number an id was pushed under, or None.
|
||||
|
||||
Read off `.remote.json` rather than the issue file: a successful push
|
||||
deletes the file, and the ledger is what is left behind."""
|
||||
for key, got in _gitea.load_map(self.root).items():
|
||||
if got == id:
|
||||
return gmap.parse_remote_key(key)[1]
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# _gitea: the POST body, and the pre-check that reads links back
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class AddDependencyTest(unittest.TestCase):
|
||||
|
||||
def test_post_body_is_issue_meta(self):
|
||||
"""POST /issues/{index}/dependencies with IssueMeta for the BLOCKER.
|
||||
|
||||
Confirmed against the instance's swagger.v1.json (Gitea 1.26.1):
|
||||
"Make the issue in the url depend on the issue in the form." """
|
||||
calls = []
|
||||
|
||||
def fake_api(login, endpoint, method="GET", payload=None, **kw):
|
||||
calls.append((method, endpoint, payload))
|
||||
return {"number": 102}
|
||||
|
||||
with mock.patch.object(_gitea, "api", fake_api):
|
||||
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101)
|
||||
|
||||
self.assertTrue(ok)
|
||||
method, endpoint, payload = calls[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertEqual(endpoint, "%s/issues/102/dependencies" % BASE)
|
||||
self.assertEqual(payload, {"index": 101, "owner": "claude-skills",
|
||||
"repo": "tea"})
|
||||
|
||||
def test_blocker_may_live_in_another_repo(self):
|
||||
"""IssueMeta carries owner/repo precisely so it can."""
|
||||
seen = {}
|
||||
|
||||
def fake_api(login, endpoint, method="GET", payload=None, **kw):
|
||||
seen.update(payload or {})
|
||||
return {"number": 1}
|
||||
|
||||
with mock.patch.object(_gitea, "api", fake_api):
|
||||
_gitea.add_dependency("l", BASE, 102, "other-org/infra", 7)
|
||||
self.assertEqual(seen, {"index": 7, "owner": "other-org", "repo": "infra"})
|
||||
|
||||
def test_failure_is_reported_not_raised(self):
|
||||
"""409 (link already there) and friends come back as False."""
|
||||
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
|
||||
self.assertFalse(_gitea.add_dependency("l", BASE, 102, REPO, 101))
|
||||
|
||||
def test_unparseable_repo_makes_no_request(self):
|
||||
called = []
|
||||
with mock.patch.object(_gitea, "api", lambda *a, **k: called.append(1)):
|
||||
self.assertFalse(_gitea.add_dependency("l", BASE, 102, "tea", 101))
|
||||
self.assertEqual(called, [])
|
||||
|
||||
def test_native_dep_pairs_reads_repo_and_number(self):
|
||||
payload = [{"number": 101, "repository": {"full_name": REPO}},
|
||||
{"number": 7, "repository": {"full_name": "other-org/infra"}}]
|
||||
with mock.patch.object(_gitea, "api", lambda *a, **k: payload):
|
||||
got = _gitea.native_dep_pairs("l", BASE, 102)
|
||||
self.assertEqual(got, {(REPO, 101), ("other-org/infra", 7)})
|
||||
|
||||
def test_native_dep_pairs_empty_when_unsupported(self):
|
||||
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
|
||||
self.assertEqual(_gitea.native_dep_pairs("l", BASE, 102), set())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# push: the whole run
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class PushCreatesLinksTest(PushTestCase):
|
||||
|
||||
def test_link_created_after_both_have_numbers(self):
|
||||
"""One run, topological order, one native link — no second pass."""
|
||||
self.two_issues()
|
||||
out, _ = self.run_push()
|
||||
|
||||
first, second = self.number_of("first-thing"), self.number_of("second-thing")
|
||||
self.assertLess(first, second, "blocker must be created first")
|
||||
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
|
||||
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
|
||||
|
||||
def test_link_direction_matches_what_pull_reads_back(self):
|
||||
"""The link hangs off the BLOCKED issue, which is where native_deps
|
||||
looks — push and `pull.py --deps` must agree or the round trip lies."""
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
second = self.number_of("second-thing")
|
||||
with mock.patch.object(_gitea, "api", self.fake.api):
|
||||
self.assertEqual(_gitea.native_deps("l", BASE, second),
|
||||
[self.number_of("first-thing")])
|
||||
|
||||
def test_issue_without_dependencies_makes_no_dependency_request(self):
|
||||
"""Not even the idempotence GET — it is skipped when there is nothing
|
||||
to link, so the common case costs no extra round trip."""
|
||||
self.write_issue("lonely-thing", "Lonely thing")
|
||||
self.run_push()
|
||||
self.assertEqual([c for c in self.fake.calls if "dependencies" in c[1]], [])
|
||||
|
||||
|
||||
class LocalOnlyDependencyTest(PushTestCase):
|
||||
|
||||
def test_local_dependency_is_warned_and_not_linked(self):
|
||||
self.two_issues()
|
||||
out, err = self.run_push("second-thing")
|
||||
|
||||
self.assertEqual(self.fake.dep_posts(), [])
|
||||
self.assertIn("depends on local-only issue(s) first-thing", err)
|
||||
self.assertNotIn("depends on ", out)
|
||||
self.assertIsNone(self.number_of("first-thing"))
|
||||
|
||||
|
||||
class IdempotenceTest(PushTestCase):
|
||||
|
||||
def test_repeat_push_does_not_duplicate_the_link(self):
|
||||
self.two_issues()
|
||||
self.run_push()
|
||||
self.assertEqual(len(self.fake.dep_posts()), 1)
|
||||
|
||||
self.repull("first-thing")
|
||||
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
||||
self.run_push("--update")
|
||||
self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed")
|
||||
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
||||
{(REPO, self.number_of("first-thing"))})
|
||||
|
||||
def test_a_failing_link_warns_and_the_run_finishes(self):
|
||||
"""A 409 or any other refusal must not abort a push that has already
|
||||
created issues."""
|
||||
self.two_issues()
|
||||
self.fake.fail_dependency_post = True
|
||||
out, err = self.run_push()
|
||||
|
||||
self.assertIn("could not link", err)
|
||||
self.assertIn("index:", out) # the run completed
|
||||
self.assertIsNotNone(self.number_of("second-thing"))
|
||||
|
||||
|
||||
class UpdateCarriesNewLinksTest(PushTestCase):
|
||||
|
||||
def test_dependency_added_after_the_first_push_is_linked_by_update(self):
|
||||
self.write_issue("first-thing", "First thing")
|
||||
self.write_issue("second-thing", "Second thing")
|
||||
self.run_push()
|
||||
self.assertEqual(self.fake.dep_posts(), [])
|
||||
|
||||
# The issue comes back from Gitea, and the dependency is added to the
|
||||
# copy that came back — there is no other copy to add it to.
|
||||
self.repull("second-thing", body=BODY, depends=["first-thing"])
|
||||
|
||||
self.run_push("--update", "second-thing")
|
||||
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
|
||||
{(REPO, self.number_of("first-thing"))})
|
||||
|
||||
|
||||
class DryRunTest(PushTestCase):
|
||||
|
||||
def test_dry_run_names_the_links_and_writes_nothing(self):
|
||||
self.two_issues()
|
||||
out, _ = self.run_push("--dry-run")
|
||||
|
||||
self.assertEqual(self.fake.calls, [], "--dry-run made a request")
|
||||
self.assertIn("link -> #? (first-thing, created by this run)", out)
|
||||
self.assertIn("1 dependency link(s) would be created", out)
|
||||
|
||||
def test_dry_run_shows_a_known_number_when_the_blocker_is_pushed(self):
|
||||
self.write_issue("first-thing", "First thing",
|
||||
extra={"gitea": "%s#101" % REPO})
|
||||
self.write_issue("second-thing", "Second thing", body=BODY,
|
||||
depends=["first-thing"])
|
||||
out, _ = self.run_push("--dry-run")
|
||||
|
||||
self.assertIn("link -> %s#101 (first-thing)" % REPO, out)
|
||||
self.assertEqual(self.fake.writes, [])
|
||||
|
||||
def test_dry_run_says_a_local_dependency_gets_no_link(self):
|
||||
self.two_issues()
|
||||
out, _ = self.run_push("--dry-run", "second-thing")
|
||||
self.assertIn("no link: first-thing is local-only", out)
|
||||
self.assertIn("0 dependency link(s) would be created", out)
|
||||
|
||||
|
||||
class BodyIsVerbatimTest(PushTestCase):
|
||||
"""The prose is untouched. The id marker is the one thing push adds, and it
|
||||
comes straight back off — `strip_id_marker` is the inverse."""
|
||||
|
||||
def test_depends_on_prose_is_not_rewritten_to_numbers(self):
|
||||
"""map.py deliberately never edits the prose. Linking must not start."""
|
||||
self.two_issues()
|
||||
before = issue.load(self.root, "second-thing").body
|
||||
self.run_push()
|
||||
|
||||
created = [c for c in self.fake.calls
|
||||
if c[0] == "POST" and c[1] == "%s/issues" % BASE]
|
||||
sent = [c[2]["body"] for c in created]
|
||||
second_body = [b for b in sent if "Depends on" in b][0]
|
||||
|
||||
self.assertIn("- first-thing — ставит фундамент", second_body)
|
||||
self.assertNotIn("#101", second_body)
|
||||
self.assertEqual(gmap.strip_id_marker(second_body), before)
|
||||
|
||||
def test_body_survives_a_second_push_unchanged(self):
|
||||
self.two_issues()
|
||||
before = issue.load(self.root, "second-thing").body
|
||||
self.run_push()
|
||||
|
||||
self.repull("first-thing")
|
||||
self.repull("second-thing", body=before, depends=["first-thing"])
|
||||
self.assertEqual(issue.load(self.root, "second-thing").body, before)
|
||||
|
||||
self.run_push("--update")
|
||||
patched = [c for c in self.fake.calls if c[0] == "PATCH"]
|
||||
self.assertIn(before, [gmap.strip_id_marker(c[2]["body"]) for c in patched])
|
||||
|
||||
|
||||
class DepStateTest(PushTestCase):
|
||||
"""The classifier both the dry run and the real run read from."""
|
||||
|
||||
def test_classifies_linked_in_run_and_local(self):
|
||||
issues = {
|
||||
"pushed": issue.Issue(id="pushed", extra={"gitea": "%s#101" % REPO}),
|
||||
"coming": issue.Issue(id="coming"),
|
||||
"local": issue.Issue(id="local"),
|
||||
}
|
||||
iss = issue.Issue(id="dependent",
|
||||
depends=["pushed", "coming", "local", "ghost"])
|
||||
got = push.dep_state(iss, issues, {"coming", "dependent"})
|
||||
|
||||
self.assertEqual(got, [("pushed", "%s#101" % REPO, False),
|
||||
("coming", None, True),
|
||||
("local", None, False)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Where the issue store is, and that the answer does not depend on cwd.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
Stdlib unittest, no third-party anything — the same rule the scripts under test
|
||||
live by. `skills/*/scripts/` are not packages, so the domain module is imported
|
||||
by path.
|
||||
|
||||
Most of these tests do not touch this repository at all. They build a throwaway
|
||||
repo in a temp directory — a `.git` marker, a copy of both script layers, a
|
||||
store with two issues — and run the real scripts inside it as subprocesses with
|
||||
different working directories. That is the only honest way to test a cwd bug:
|
||||
importing the module would resolve the store once, against the wrong tree.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
|
||||
AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts")
|
||||
|
||||
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||
import issue # noqa: E402
|
||||
|
||||
|
||||
ALPHA = """\
|
||||
---
|
||||
id: alpha-issue
|
||||
state: open
|
||||
labels: [type/task]
|
||||
assignees: []
|
||||
milestone: none
|
||||
depends: []
|
||||
origin: local
|
||||
---
|
||||
# Alpha issue
|
||||
|
||||
## Summary
|
||||
Первый issue фикстуры.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Motivation
|
||||
Нужен, чтобы в store что-то лежало.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
"""
|
||||
|
||||
BETA = """\
|
||||
---
|
||||
id: beta-issue
|
||||
state: open
|
||||
labels: [type/task]
|
||||
assignees: []
|
||||
milestone: none
|
||||
depends: [alpha-issue]
|
||||
origin: local
|
||||
---
|
||||
# Beta issue
|
||||
|
||||
## Summary
|
||||
Второй issue фикстуры, зависит от первого.
|
||||
|
||||
## Spec
|
||||
none
|
||||
|
||||
## Depends on
|
||||
- alpha-issue
|
||||
|
||||
## Motivation
|
||||
Нужен, чтобы у графа было ребро.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
"""
|
||||
|
||||
|
||||
def run(script, *args, **kw):
|
||||
"""Run one of the plugin's scripts and return (rc, stdout, stderr)."""
|
||||
cwd = kw.pop("cwd")
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
|
||||
p = subprocess.run([sys.executable, script] + list(args), cwd=cwd, env=env,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout, p.stderr
|
||||
|
||||
|
||||
class FakeRepo(object):
|
||||
"""A self-contained repository in a temp directory.
|
||||
|
||||
Both script layers are copied in, so `__file__`-anchored resolution lands
|
||||
inside the fixture and never on the developer's real store.
|
||||
"""
|
||||
|
||||
def __init__(self, with_store=True, issues=(ALPHA, BETA)):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
# realpath: on macOS $TMPDIR is a symlink, and a child process reporting
|
||||
# its own cwd would otherwise disagree with the path we handed it.
|
||||
self.root = os.path.realpath(self._tmp.name)
|
||||
|
||||
os.makedirs(os.path.join(self.root, ".git")) # the repo marker
|
||||
skip = shutil.ignore_patterns("__pycache__")
|
||||
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
|
||||
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
|
||||
# the transport resolves the login pin through skills/auth/scripts
|
||||
shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip)
|
||||
os.makedirs(self.path("sub", "deeper"))
|
||||
|
||||
if with_store:
|
||||
os.makedirs(self.store)
|
||||
for text in issues:
|
||||
id = text.split("id: ", 1)[1].split("\n", 1)[0]
|
||||
with open(os.path.join(self.store, "%s.md" % id), "w") as f:
|
||||
f.write(text)
|
||||
|
||||
def cleanup(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def path(self, *parts):
|
||||
return os.path.join(self.root, *parts)
|
||||
|
||||
@property
|
||||
def store(self):
|
||||
return self.path("tmp", "issues")
|
||||
|
||||
def script(self, layer, name):
|
||||
return self.path("skills", layer, "scripts", name)
|
||||
|
||||
def everywhere(self):
|
||||
"""Working directories that must all produce the same answer: the repo
|
||||
root, a plain subdirectory, a deeper one, the script directory itself,
|
||||
and — the case from the bug report — inside the store."""
|
||||
return [self.root, self.path("sub"), self.path("sub", "deeper"),
|
||||
self.path("skills", "issue", "scripts"), self.store]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# resolution, in isolation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestResolution(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def test_repo_root_found_from_any_depth(self):
|
||||
for start in self.repo.everywhere():
|
||||
self.assertEqual(issue.repo_root(start), self.repo.root, start)
|
||||
|
||||
def test_agents_md_works_as_a_marker(self):
|
||||
"""A checkout without .git — the plugin copied out of git — still
|
||||
resolves, because AGENTS.md marks the root too."""
|
||||
shutil.rmtree(self.repo.path(".git"))
|
||||
open(self.repo.path("AGENTS.md"), "w").close()
|
||||
self.assertEqual(issue.repo_root(self.repo.path("sub", "deeper")),
|
||||
self.repo.root)
|
||||
|
||||
def test_nearest_marker_wins(self):
|
||||
"""A repo inside a repo (a worktree, a vendored copy) resolves to the
|
||||
inner one, not the outer."""
|
||||
inner = self.repo.path("sub", "inner")
|
||||
os.makedirs(os.path.join(inner, ".git"))
|
||||
self.assertEqual(issue.repo_root(inner), inner)
|
||||
self.assertEqual(issue.repo_root(self.repo.root), self.repo.root)
|
||||
|
||||
def test_store_root_is_repo_root_plus_tmp_issues(self):
|
||||
self.assertEqual(issue.store_root(self.repo.path("sub", "deeper")),
|
||||
self.repo.store)
|
||||
|
||||
def test_default_root_is_absolute(self):
|
||||
"""The whole point: a default that cannot mean two directories."""
|
||||
self.assertTrue(os.path.isabs(issue.ISSUE_ROOT), issue.ISSUE_ROOT)
|
||||
self.assertEqual(issue.ISSUE_ROOT,
|
||||
os.path.join(REPO, "tmp", "issues"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the acceptance criterion: same answer from any subdirectory
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestSameFromAnywhere(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def assertSameEverywhere(self, layer, name, *args):
|
||||
"""Run the script from the repo root and from every other directory;
|
||||
every result must be byte-identical to the one from the root."""
|
||||
dirs = self.repo.everywhere()
|
||||
base = run(self.repo.script(layer, name), *args, cwd=dirs[0])
|
||||
self.assertEqual(base[0], 0, "%s failed at the repo root:\n%s" % (name, base[2]))
|
||||
for d in dirs[1:]:
|
||||
self.assertEqual(run(self.repo.script(layer, name), *args, cwd=d), base,
|
||||
"%s disagrees when run from %s" % (name, d))
|
||||
return base
|
||||
|
||||
def test_issue_check(self):
|
||||
rc, out, _ = self.assertSameEverywhere("issue", "issue_check.py")
|
||||
self.assertIn("ok alpha-issue", out)
|
||||
self.assertIn("2 issue(s) checked, 0 with errors", out)
|
||||
|
||||
def test_issue_tree(self):
|
||||
_, out, _ = self.assertSameEverywhere("issue", "issue_tree.py")
|
||||
self.assertIn("beta-issue", out)
|
||||
self.assertIn("alpha-issue", out)
|
||||
|
||||
def test_issue_index(self):
|
||||
_, out, _ = self.assertSameEverywhere("issue", "issue_index.py")
|
||||
self.assertIn("2 issue(s)", out)
|
||||
self.assertIn(os.path.join(self.repo.store, "INDEX.md"), out)
|
||||
|
||||
def test_no_second_store_is_ever_created(self):
|
||||
"""The bug's worst symptom: `issue_index.py` run from inside the store
|
||||
used to leave tmp/issues/tmp/issues/ behind, silently."""
|
||||
for d in self.repo.everywhere():
|
||||
for name in ("issue_index.py", "issue_check.py", "issue_tree.py"):
|
||||
run(self.repo.script("issue", name), cwd=d)
|
||||
|
||||
found = []
|
||||
for dirpath, dirnames, filenames in os.walk(self.repo.root):
|
||||
if "__pycache__" in dirnames:
|
||||
dirnames.remove("__pycache__")
|
||||
if "INDEX.md" in filenames:
|
||||
found.append(dirpath)
|
||||
self.assertEqual(found, [self.repo.store],
|
||||
"a second store appeared: %s" % found)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# missing is not empty
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestMissingVersusEmpty(unittest.TestCase):
|
||||
|
||||
def test_missing_store_says_missing(self):
|
||||
repo = FakeRepo(with_store=False)
|
||||
self.addCleanup(repo.cleanup)
|
||||
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
||||
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
|
||||
msg = out + err
|
||||
self.assertNotEqual(rc, 0, "%s should fail on a missing store" % name)
|
||||
self.assertIn("does not exist", msg, name)
|
||||
self.assertNotIn("is empty", msg, name)
|
||||
|
||||
def test_empty_store_says_empty(self):
|
||||
repo = FakeRepo(issues=())
|
||||
self.addCleanup(repo.cleanup)
|
||||
for name in ("issue_check.py", "issue_tree.py"):
|
||||
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
|
||||
msg = out + err
|
||||
self.assertNotEqual(rc, 0, name)
|
||||
self.assertIn("is empty", msg, name)
|
||||
self.assertNotIn("does not exist", msg, name)
|
||||
|
||||
def test_index_of_an_empty_store_is_legitimate(self):
|
||||
"""An existing store with nothing in it gets an index saying so. Only a
|
||||
missing directory is an error."""
|
||||
repo = FakeRepo(issues=())
|
||||
self.addCleanup(repo.cleanup)
|
||||
rc, out, err = run(repo.script("issue", "issue_index.py"), cwd=repo.root)
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("0 issue(s)", out)
|
||||
with open(os.path.join(repo.store, "INDEX.md")) as f:
|
||||
self.assertIn("_empty_", f.read())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# nothing conjures a store
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestNoSilentCreation(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo(with_store=False)
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def test_readers_and_the_indexer_create_nothing(self):
|
||||
for d in (self.repo.root, self.repo.path("sub")):
|
||||
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
|
||||
run(self.repo.script("issue", name), cwd=d)
|
||||
self.assertFalse(os.path.exists(self.repo.path("tmp")),
|
||||
"the store was created by a read")
|
||||
self.assertFalse(os.path.exists(self.repo.path("sub", "tmp")),
|
||||
"a store was created relative to cwd")
|
||||
|
||||
def test_explicit_out_pointing_nowhere_is_an_error_not_a_mkdir(self):
|
||||
target = self.repo.path("sub", "nowhere")
|
||||
rc, out, err = run(self.repo.script("issue", "issue_index.py"),
|
||||
"--out", target, cwd=self.repo.root)
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("does not exist", out + err)
|
||||
self.assertFalse(os.path.exists(target))
|
||||
|
||||
def test_issue_new_creates_the_store_and_says_so(self):
|
||||
"""Creating the first issue in a fresh checkout must still work — but
|
||||
out loud, and at the repo root, not below whatever cwd happens to be."""
|
||||
rc, out, err = run(self.repo.script("issue", "issue_new.py"),
|
||||
"--type", "task", "--title", "Bootstrap the store",
|
||||
cwd=self.repo.path("sub", "deeper"))
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("created store", err)
|
||||
self.assertIn(self.repo.store, err)
|
||||
self.assertTrue(os.path.isfile(
|
||||
os.path.join(self.repo.store, "bootstrap-the-store.md")))
|
||||
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")),
|
||||
"a store was created relative to cwd")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# an explicit --out is the operator's, not ours to rewrite
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestExplicitOutWins(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def test_absolute_out_is_honored(self):
|
||||
other = self.repo.path("sub", "other-store")
|
||||
os.makedirs(other)
|
||||
shutil.copy(os.path.join(self.repo.store, "alpha-issue.md"), other)
|
||||
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||
"--out", other, cwd=self.repo.root)
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("1 issue(s) checked", out)
|
||||
|
||||
def test_relative_out_stays_relative_to_cwd(self):
|
||||
"""`--out tmp/issues` typed from a subdirectory means that
|
||||
subdirectory's tmp/issues — which is not there. Auto-resolution must
|
||||
not step in and "fix" what the operator typed."""
|
||||
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||
"--out", os.path.join("tmp", "issues"),
|
||||
cwd=self.repo.path("sub"))
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("does not exist", out + err)
|
||||
|
||||
# the same relative path from the root does resolve, by cwd alone
|
||||
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||
"--out", os.path.join("tmp", "issues"),
|
||||
cwd=self.repo.root)
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("2 issue(s) checked", out)
|
||||
|
||||
def test_relative_out_can_climb(self):
|
||||
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
|
||||
"--out", os.path.join("..", "tmp", "issues"),
|
||||
cwd=self.repo.path("sub"))
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("2 issue(s) checked", out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# both layers, one root
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestSyncLayerAgrees(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepo()
|
||||
self.addCleanup(self.repo.cleanup)
|
||||
|
||||
def _probe(self, layer, cwd):
|
||||
"""Ask one layer, from `cwd`, which module defines the store and where
|
||||
it lands. The sync scripts put the issue scripts on sys.path themselves
|
||||
— `import map` is how they do it — so each layer is asked its own way.
|
||||
"""
|
||||
scripts = self.repo.path("skills", layer, "scripts")
|
||||
entry = "import map, issue" if layer == "sync" else "import issue"
|
||||
code = ("import sys; sys.path.insert(0, %r)\n%s\n"
|
||||
"print(issue.__file__)\nprint(issue.ISSUE_ROOT)\n") % (scripts, entry)
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONPATH", None)
|
||||
p = subprocess.run([sys.executable, "-c", code], cwd=cwd, env=env,
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
return p.stdout.strip().splitlines()
|
||||
|
||||
def test_both_layers_resolve_the_same_store_from_anywhere(self):
|
||||
for d in self.repo.everywhere():
|
||||
mod_i, root_i = self._probe("issue", d)
|
||||
mod_s, root_s = self._probe("sync", d)
|
||||
# sync does not redefine the store; it imports the domain module
|
||||
self.assertEqual(os.path.realpath(mod_i), os.path.realpath(mod_s), d)
|
||||
self.assertEqual(root_i, self.repo.store, d)
|
||||
self.assertEqual(root_s, self.repo.store, d)
|
||||
|
||||
def test_every_out_flag_defers_to_the_domain_layer(self):
|
||||
"""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_evict.py")),
|
||||
("sync", ("pull.py", "push.py", "remote.py",
|
||||
"comment.py", "evict.py"))):
|
||||
for name in names:
|
||||
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
|
||||
src = f.read()
|
||||
self.assertIn('"--out", default=issue.ISSUE_ROOT', src,
|
||||
"%s/%s does not take its --out default from the "
|
||||
"domain layer" % (layer, name))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the layering rule, mechanically
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestLayering(unittest.TestCase):
|
||||
|
||||
def test_domain_layer_is_stdlib_only(self):
|
||||
"""skills/issue must keep working with skills/sync deleted — so no
|
||||
transport, and above all no subprocess, in the domain layer."""
|
||||
imported = set()
|
||||
for name in sorted(os.listdir(ISSUE_SCRIPTS)):
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
with open(os.path.join(ISSUE_SCRIPTS, name)) as f:
|
||||
for line in f:
|
||||
if line.startswith(("import ", "from ")):
|
||||
imported.add(line.split()[1].split(".")[0])
|
||||
local = {"issue", "issue_ac", "issue_index"}
|
||||
foreign = imported - local - sys.stdlib_module_names
|
||||
self.assertEqual(foreign, set(),
|
||||
"non-stdlib import in the domain layer: %s"
|
||||
% ", ".join(sorted(foreign)))
|
||||
self.assertNotIn("subprocess", imported)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
name: issue
|
||||
description: Work with this project's issues as units of work — create, read, grep, validate, and walk their dependency graph. Entirely offline; issues are local markdown files and need no tracker. Load when the user asks to file/create an issue, read or find issues, check an issue against the format, or see what depends on what. For pushing to or pulling from Gitea, load /tea:sync instead.
|
||||
---
|
||||
|
||||
# /tea:issue — issues as units of work
|
||||
|
||||
An issue is a markdown file in `tmp/issues/`. This skill covers everything you
|
||||
do **with** an issue: writing one, reading one, checking it against the
|
||||
canonical format, and walking the dependency graph.
|
||||
|
||||
**Nothing here touches the network.** No `tea`, no Gitea, no login. An issue
|
||||
that lives only on this machine is a first-class issue, not a draft waiting to
|
||||
be uploaded. Synchronizing with a tracker is a separate, optional layer —
|
||||
`/tea:sync`.
|
||||
|
||||
Read [`references/format.md`](references/format.md) before creating or editing
|
||||
an issue. It is the single source of truth for identity, metadata, types,
|
||||
labels, templates, and language rules.
|
||||
|
||||
## Identity: the slug
|
||||
|
||||
The file name is the id and the id is a slug — `tmp/issues/wire-sqlc-appclick.md`.
|
||||
It never changes, not when the title changes and not when the issue is pushed
|
||||
somewhere. Tracker numbers live in a metadata field (`gitea: owner/repo#42`),
|
||||
never in a file name and never in `depends:`.
|
||||
|
||||
Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to
|
||||
issues by id.
|
||||
|
||||
## Scripts
|
||||
|
||||
All offline, all in `<skill-base-dir>/scripts/`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `issue_new.py --type T --title "…"` | create `tmp/issues/<slug>.md` from the type's template |
|
||||
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
|
||||
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
|
||||
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
|
||||
| `issue.py` | the domain module the others import — not a command |
|
||||
|
||||
```
|
||||
tmp/issues/INDEX.md table of every issue — read this first
|
||||
tmp/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
|
||||
tmp/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
|
||||
tmp/issues/tree-<id>.md saved graph (issue_tree.py --write)
|
||||
```
|
||||
|
||||
## Reading: grep, don't parse
|
||||
|
||||
Metadata is one field per line with inline lists precisely so plain `grep`
|
||||
works. `INDEX.md` first, then the files:
|
||||
|
||||
```bash
|
||||
grep -l 'labels:.*type/bug' tmp/issues/*.md # all bugs
|
||||
grep -l 'origin: local' tmp/issues/*.md # never pushed anywhere
|
||||
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
|
||||
grep -A3 '## Acceptance criteria' tmp/issues/wire-*.md
|
||||
grep -c '^- \[ \]' tmp/issues/wire-sqlc-appclick.md # open checkboxes
|
||||
```
|
||||
|
||||
Read whole files only for the issues the task actually needs.
|
||||
|
||||
## Creating an issue
|
||||
|
||||
1. **Read the format**: [`references/format.md`](references/format.md).
|
||||
2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a
|
||||
container for several issues with one business value), or `draft` (an idea
|
||||
not ready for work). If it is not obvious from the request, ask the user
|
||||
(one question).
|
||||
3. **Scaffold it:**
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_new.py \
|
||||
--type task --title "Wire sqlc into the appclick repo layer" \
|
||||
--label tech/sql --label comp/appclick --depends migrate-schema
|
||||
```
|
||||
English imperative title with no type prefix; `--depends` takes ids.
|
||||
4. **Fill the sections** with Edit — every section of the template present and
|
||||
in order, headers English, prose Russian. `## Spec` gets a repo path, a URL,
|
||||
or the literal `none`; ask the user if you cannot determine which.
|
||||
5. **Check it:**
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
||||
```
|
||||
|
||||
One file = one issue. Several related issues = several files, linked through
|
||||
`depends:`.
|
||||
|
||||
The issue is now real and complete. Publishing it to Gitea is a separate
|
||||
decision — `/tea:sync` — and does not change the file's status here.
|
||||
|
||||
## Editing an issue
|
||||
|
||||
Edit the file. Change `state:` to close it, edit `labels:`, tick checkboxes in
|
||||
`## Acceptance criteria`, add ids to `depends:`. Re-run `issue_check.py`
|
||||
afterwards, and `issue_index.py` to refresh the table.
|
||||
|
||||
If the issue is synced (`origin: gitea`), your edit is local until you run
|
||||
`push.py --update` from `/tea:sync`. Nothing tracks that drift automatically.
|
||||
|
||||
## Dependency graph
|
||||
|
||||
`depends:` is the authoritative edge list; the body's `## Depends on` section
|
||||
is prose for humans. `issue_check.py` warns when they disagree.
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_tree.py # all roots
|
||||
python3 <skill-base-dir>/scripts/issue_tree.py wire-sqlc-appclick --write
|
||||
```
|
||||
|
||||
A `type/feature` plus its children read as one document: draw the tree once for
|
||||
the shape, then grep the files.
|
||||
|
||||
## Layering rule
|
||||
|
||||
This skill must keep working with `skills/sync/` deleted. Every import under
|
||||
`scripts/` is stdlib, and `subprocess` is not among them:
|
||||
|
||||
```bash
|
||||
grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u
|
||||
```
|
||||
|
||||
If you find yourself wanting a tracker concept here — an issue number, a login,
|
||||
an HTTP call — it belongs in `/tea:sync`.
|
||||
@@ -1,173 +0,0 @@
|
||||
---
|
||||
name: sync
|
||||
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, or comment on one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
|
||||
---
|
||||
|
||||
# /tea:sync — the bridge between the local store and Gitea
|
||||
|
||||
One job: translate between `tmp/issues/<id>.md` and Gitea's JSON, and carry the
|
||||
result over the wire. Everything about **what an issue is** — format, types,
|
||||
validation, the dependency graph — belongs to `/tea:issue` and is imported from
|
||||
there, never redefined here.
|
||||
|
||||
Direction of knowledge, and it is one-way:
|
||||
|
||||
```
|
||||
skills/issue domain what an issue is offline, no tracker
|
||||
▲
|
||||
│ imports
|
||||
skills/sync bridge map.py md <-> Gitea JSON, pure, no I/O
|
||||
_gitea.py login, tea api, pagination, filters
|
||||
```
|
||||
|
||||
`skills/issue` never imports anything from here.
|
||||
|
||||
## Never read an issue through raw `tea`
|
||||
|
||||
`tea issues <n> -o json` and `tea api .../issues/<n>` dump the full payload —
|
||||
avatars, nested user objects, every comment body — into your context whether
|
||||
you need it or not. Use `pull.py`: it writes flat markdown and prints a compact
|
||||
index.
|
||||
|
||||
## Scripts
|
||||
|
||||
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
|
||||
operator's pin from `.claude/settings.local.json` themselves, the same source
|
||||
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing |
|
||||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md` |
|
||||
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success |
|
||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
|
||||
|
||||
Key forms for `<key>`: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo
|
||||
defaults to the current directory's git remote; add `--repo owner/repo` outside
|
||||
one.
|
||||
|
||||
## Identity mapping
|
||||
|
||||
The local id is a slug; Gitea's is a number. The pair is recorded in the issue
|
||||
file itself:
|
||||
|
||||
```
|
||||
origin: gitea
|
||||
gitea: claude-skills/tea#42
|
||||
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
```
|
||||
|
||||
`tmp/issues/.remote.json` indexes those fields for fast lookup. It is a cache
|
||||
over the files, not a second source of truth — delete it and the next command
|
||||
rebuilds it.
|
||||
|
||||
A retitled issue keeps its slug: the map is keyed by number, so a pull updates
|
||||
the existing file instead of creating a second one.
|
||||
|
||||
## Pulling
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/pull.py 42
|
||||
python3 <skill-base-dir>/scripts/pull.py --milestone 6 # id or title
|
||||
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
|
||||
python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20
|
||||
python3 <skill-base-dir>/scripts/pull.py 40 --deps # follow dependencies
|
||||
```
|
||||
|
||||
Do not loop over numbers to pull a group — pass the filter. The list endpoint
|
||||
carries the issue bodies, so a milestone costs **one request per 50 issues**,
|
||||
not one per issue. Filters AND together; `--state` defaults to `open`;
|
||||
`--limit` to 100. Keys and filters are mutually exclusive.
|
||||
|
||||
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
||||
local edits are lost. `--cached` skips issues already on disk.
|
||||
|
||||
Two traps this handles for you:
|
||||
|
||||
- **Gitea silently ignores an unresolvable milestone filter** and returns the
|
||||
whole backlog. `pull.py` resolves the milestone first (exiting with the real
|
||||
ones if it does not exist) and re-checks every returned issue locally. Never
|
||||
trust a raw `tea api ...issues?milestones=X` for this.
|
||||
- **Projects are not fetchable.** The projects API is not exposed (404 on
|
||||
Gitea 1.26 for `repos/…/projects`, `orgs/…/projects`, `projects/{id}`). Use
|
||||
milestones or labels; project columns live in the web UI only.
|
||||
|
||||
After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no
|
||||
extra requests.
|
||||
|
||||
## Pushing
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/push.py --dry-run # validate, no network
|
||||
python3 <skill-base-dir>/scripts/push.py # every local-only issue
|
||||
python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
|
||||
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
|
||||
```
|
||||
|
||||
**Pushing is additive: the local file is never deleted.** It gains `gitea:`,
|
||||
`url:`, `synced:`, and `origin:` flips to `gitea`. One issue, visible in two
|
||||
places — not two kinds of file.
|
||||
|
||||
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
|
||||
at most one `severity/*`, English title with no type prefix, `## Summary` /
|
||||
`## Spec` / `## Acceptance criteria` present). `--force` posts anyway — say why
|
||||
when you use it.
|
||||
|
||||
Issues go up in topological order, dependencies first. A dependency that is
|
||||
still local-only is reported, not silently dropped: the body's `## Depends on`
|
||||
prose is sent verbatim either way, but the `#N` cross-link will be missing
|
||||
until that issue is pushed too.
|
||||
|
||||
Missing labels are created with the canonical color and, for `type/*` and
|
||||
`severity/*`, `exclusive: true` — `tea labels create` cannot set that field
|
||||
(tea 0.14.2), so it goes through `tea api`. Colors live in `map.py`; the names
|
||||
and their meaning come from the domain taxonomy.
|
||||
|
||||
A milestone must already exist in the repo — push attaches, it does not create.
|
||||
|
||||
## What crosses the boundary, and what does not
|
||||
|
||||
| domain | Gitea | note |
|
||||
|---|---|---|
|
||||
| `id` (slug) | — | local only; the tracker never sees it |
|
||||
| title, body | `title`, `body` | verbatim, both directions |
|
||||
| `state` | `state` | same vocabulary |
|
||||
| `labels` | `labels[]` | names both ways; ids only on write |
|
||||
| `assignees` | `assignees[]` | logins |
|
||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
||||
| `depends` | — | slugs; seeded from `#N` on pull |
|
||||
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
||||
|
||||
`depends:` is always slugs. The body's `## Depends on` section is human prose
|
||||
and is passed through **unchanged** in both directions: a pull seeds `depends:`
|
||||
from the `#N` it finds there, a push never rewrites what the author wrote. A
|
||||
translator that edits prose churns the body on every round trip.
|
||||
|
||||
Comments are **pull-only** in the store: `<id>.comments.md` is written by
|
||||
`pull.py --comments` and `comment.py`, and editing it by hand changes nothing
|
||||
in Gitea.
|
||||
|
||||
## Drift
|
||||
|
||||
There is none tracked. The store is not a mirror: nothing watches Gitea,
|
||||
nothing reconciles, nothing warns that a synced issue changed upstream.
|
||||
`synced:` tells you how old your copy is; `remote-updated:` what the server
|
||||
said at that moment. Re-pull when it matters.
|
||||
|
||||
## Rich payloads for everything else
|
||||
|
||||
Comments and issues are wrapped by the scripts above. For **other** entities
|
||||
(pulls, releases, PATCHing something these scripts do not cover), entity
|
||||
subcommands like `tea pulls create` hang on a large or formatted body — an
|
||||
empty-looking positional triggers the `$EDITOR` fallback on a TTY that does not
|
||||
exist, and the harness eventually kills the process (exit 144 = 128 + SIGURG on
|
||||
macOS). Write the JSON payload to `$PWD/tmp/` first and POST it with
|
||||
`tea api -d @file`. Procedure and endpoint table: `/tea:use`.
|
||||
|
||||
## Login
|
||||
|
||||
Every `tea` call made by hand must carry the literal placeholder
|
||||
`--login "$GITEA_LOGIN"`; the `tea-guard` hook substitutes the operator's pin.
|
||||
Set it with `/tea:auth`. Details in `/tea:use`.
|
||||
@@ -1,316 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
_gitea.py — transport. Everything that talks to Gitea, and nothing else.
|
||||
|
||||
Not a command. This module knows logins, HTTP verbs, pagination, and Gitea's
|
||||
query quirks. It does NOT know what an issue is: no sections, no acceptance
|
||||
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
|
||||
lives one layer further out in skills/issue/scripts/issue.py.
|
||||
|
||||
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up
|
||||
from CWD — the same file /tea:auth writes and the tea-guard hook reads. No
|
||||
script here accepts a login argument: the operator's pin is the only identity
|
||||
they will use. No pin -> exit with a pointer to /tea:auth.
|
||||
|
||||
Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with
|
||||
a local slug. It is transport bookkeeping, not domain data — the domain never
|
||||
reads it, and losing it costs a re-pull, not information.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
PAYLOAD_DIR = ".payload"
|
||||
REMOTE_MAP = ".remote.json"
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def warn(msg):
|
||||
sys.stderr.write("warning: %s\n" % msg)
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# login
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def find_pin(start_dir=None):
|
||||
"""Walk up from start_dir; return the login from the first
|
||||
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
|
||||
d = os.path.abspath(start_dir or ".")
|
||||
while True:
|
||||
p = os.path.join(d, ".claude", "settings.local.json")
|
||||
if os.path.isfile(p):
|
||||
try:
|
||||
with open(p) as f:
|
||||
v = (json.load(f).get("env") or {}).get("GITEA_LOGIN")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
except Exception:
|
||||
pass
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
def require_login():
|
||||
login = find_pin(os.getcwd())
|
||||
if not login:
|
||||
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
||||
return login
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# api
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
||||
out_root=None, allow_fail=False):
|
||||
"""Call `tea api`; return parsed JSON (None on an empty body).
|
||||
|
||||
payload (a dict) is written to <out_root>/.payload/<name>.json and passed
|
||||
as -d @file — the file survives the call for retries and debugging.
|
||||
allow_fail returns None instead of exiting when the call fails."""
|
||||
cmd = ["tea", "api", "--login", login]
|
||||
if method != "GET":
|
||||
cmd += ["-X", method]
|
||||
if payload is not None:
|
||||
pdir = os.path.join(out_root or ".", PAYLOAD_DIR)
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
path = os.path.join(pdir, "%s.json" % (payload_name or "request"))
|
||||
with open(path, "w") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
cmd += ["-d", "@" + path]
|
||||
cmd.append(endpoint)
|
||||
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
if allow_fail:
|
||||
return None
|
||||
die("`tea api %s %s` failed:\n%s" % (method, endpoint, (r.stderr or r.stdout).strip()))
|
||||
body = r.stdout.strip()
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
if allow_fail:
|
||||
return None
|
||||
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
|
||||
|
||||
|
||||
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
|
||||
"""GET a list endpoint page by page; return the concatenated list."""
|
||||
sep = "&" if "?" in endpoint else "?"
|
||||
out = []
|
||||
for page in range(1, max_pages + 1):
|
||||
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
|
||||
if not isinstance(batch, list) or not batch:
|
||||
break
|
||||
out.extend(batch)
|
||||
if len(batch) < limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def repo_base(repo=None):
|
||||
"""API prefix. Without --repo, let tea fill {owner}/{repo} from CWD."""
|
||||
return "repos/%s" % repo if repo else "repos/{owner}/{repo}"
|
||||
|
||||
|
||||
def repo_slug(login, repo=None):
|
||||
"""owner/repo as a literal string — needed for remote keys, which must not
|
||||
contain tea's {owner}/{repo} placeholder."""
|
||||
if repo:
|
||||
return repo
|
||||
got = api(login, "repos/{owner}/{repo}", allow_fail=True)
|
||||
if isinstance(got, dict) and got.get("full_name"):
|
||||
return got["full_name"]
|
||||
die("cannot determine owner/repo from the CWD — pass --repo owner/repo")
|
||||
|
||||
|
||||
def parse_key(key):
|
||||
"""Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / a URL."""
|
||||
key = key.strip()
|
||||
m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key)
|
||||
if m:
|
||||
return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2))
|
||||
m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key)
|
||||
if m:
|
||||
return int(m.group(2)), m.group(1)
|
||||
m = re.match(r'^#?(\d+)$', key)
|
||||
if m:
|
||||
return int(m.group(1)), None
|
||||
die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# filters
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def resolve_milestone(login, base, value):
|
||||
"""(id, title) for a milestone given by id or title. Exits if unknown.
|
||||
|
||||
Gitea silently IGNORES an unresolvable `milestones=` filter and returns the
|
||||
whole backlog, so the milestone must be resolved before it is trusted."""
|
||||
got = paginate(login, "%s/milestones?state=all" % base, limit=100)
|
||||
for m in got or []:
|
||||
if str(m.get("id")) == str(value) or m.get("title") == str(value):
|
||||
return m["id"], m.get("title", "")
|
||||
have = ", ".join("%s (id %d)" % (m.get("title", ""), m["id"]) for m in got or [])
|
||||
die("no milestone %r in this repo — have: %s" % (value, have or "none"))
|
||||
|
||||
|
||||
def matches(payload, milestone_id=None, labels=()):
|
||||
"""Client-side re-check of a server-side filter — see resolve_milestone."""
|
||||
if payload.get("pull_request"):
|
||||
return False
|
||||
if milestone_id is not None and (payload.get("milestone") or {}).get("id") != milestone_id:
|
||||
return False
|
||||
names = {l.get("name", "") for l in payload.get("labels") or []}
|
||||
return all(l in names for l in labels)
|
||||
|
||||
|
||||
def list_issues(login, base, state="open", labels=(), query=None,
|
||||
milestone=None, limit=100):
|
||||
"""Filtered issue payloads. Returns (payloads, milestone_title).
|
||||
|
||||
One request per page, and the payload already carries the issue bodies — a
|
||||
whole milestone costs one call per 50 issues, not one per issue."""
|
||||
ms_id, ms_title = (None, None)
|
||||
if milestone is not None:
|
||||
ms_id, ms_title = resolve_milestone(login, base, milestone)
|
||||
|
||||
params = {"state": state, "type": "issues"}
|
||||
if labels:
|
||||
params["labels"] = ",".join(labels)
|
||||
if query:
|
||||
params["q"] = query
|
||||
if ms_title:
|
||||
params["milestones"] = ms_title
|
||||
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
|
||||
|
||||
per_page = min(limit, 50)
|
||||
got = paginate(login, endpoint, limit=per_page,
|
||||
max_pages=max(1, -(-limit // per_page)))
|
||||
got = [p for p in got if matches(p, ms_id, labels)]
|
||||
return got[:limit], ms_title
|
||||
|
||||
|
||||
def get_issue(login, base, number):
|
||||
payload = api(login, "%s/issues/%d" % (base, number))
|
||||
if not isinstance(payload, dict) or "number" not in payload:
|
||||
die("issue #%d not found" % number)
|
||||
return payload
|
||||
|
||||
|
||||
def get_comments(login, base, number):
|
||||
return paginate(login, "%s/issues/%d/comments" % (base, number))
|
||||
|
||||
|
||||
def native_deps(login, base, number):
|
||||
"""Gitea's own issue-dependency links; empty when unsupported."""
|
||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
|
||||
return [i["number"] for i in got] if isinstance(got, list) else []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# labels
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def ensure_labels(login, base, specs, root):
|
||||
"""Map label name -> id, creating what the repo is missing.
|
||||
|
||||
`specs` is {name: {"color", "description", "exclusive"}} handed in by the
|
||||
caller — this module does not know which namespaces are exclusive or what
|
||||
they mean. Cached in <root>/.labels.json; the cache is refreshed from the
|
||||
API before anything is created."""
|
||||
cache_path = os.path.join(root, ".labels.json")
|
||||
cache = {}
|
||||
if os.path.isfile(cache_path):
|
||||
try:
|
||||
with open(cache_path) as f:
|
||||
cache = json.load(f)
|
||||
except Exception:
|
||||
cache = {}
|
||||
|
||||
if any(n not in cache for n in specs):
|
||||
cache = {l["name"]: l["id"] for l in paginate(login, "%s/labels" % base, limit=100)}
|
||||
|
||||
for name, spec in specs.items():
|
||||
if name in cache:
|
||||
continue
|
||||
payload = dict(spec, name=name)
|
||||
created = api(login, "%s/labels" % base, "POST", payload,
|
||||
payload_name="label-%s" % name.replace("/", "-"), out_root=root)
|
||||
if not created or "id" not in created:
|
||||
die("could not create label %r" % name)
|
||||
cache[name] = created["id"]
|
||||
sys.stderr.write("created label %s%s\n"
|
||||
% (name, " (exclusive)" if spec.get("exclusive") else ""))
|
||||
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(cache_path, "w") as f:
|
||||
json.dump(cache, f, indent=2, sort_keys=True)
|
||||
return {n: cache[n] for n in specs}
|
||||
|
||||
|
||||
def resolve_milestone_id(login, base, title):
|
||||
"""Milestone id for a title, or None when the repo has no such milestone."""
|
||||
if not title or title == "none":
|
||||
return None
|
||||
for m in paginate(login, "%s/milestones?state=all" % base, limit=100) or []:
|
||||
if m.get("title") == title:
|
||||
return m["id"]
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# id map: remote key <-> local slug
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def map_path(root):
|
||||
return os.path.join(root, REMOTE_MAP)
|
||||
|
||||
|
||||
def load_map(root):
|
||||
"""{"owner/repo#42": "wire-sqlc-appclick"}"""
|
||||
p = map_path(root)
|
||||
if not os.path.isfile(p):
|
||||
return {}
|
||||
try:
|
||||
with open(p) as f:
|
||||
got = json.load(f)
|
||||
return got if isinstance(got, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_map(root, m):
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(map_path(root), "w") as f:
|
||||
json.dump(m, f, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def rebuild_map(root, issues):
|
||||
"""Recover the id map from the `gitea:` fields on disk. The files are the
|
||||
source of truth; .remote.json is only an index over them."""
|
||||
m = {}
|
||||
for id, iss in issues.items():
|
||||
key = iss.extra.get("gitea")
|
||||
if key:
|
||||
m[key] = id
|
||||
save_map(root, m)
|
||||
return m
|
||||
@@ -1,195 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
map.py — md <-> Gitea 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 domain Issue; give it an Issue 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 (issue.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
|
||||
----------------------------------------------------------------------
|
||||
id (slug) — local only; the tracker never sees it
|
||||
title, body title, body verbatim, both ways
|
||||
state state open/closed, same vocabulary
|
||||
labels labels[] names both ways; ids only on write
|
||||
assignees assignees[] logins
|
||||
milestone milestone.title resolved to an id on write
|
||||
depends — slugs; #N is translated at the edge
|
||||
— number, html_url lands in extra as gitea:/url:
|
||||
|
||||
`depends:` is the authoritative graph and is always slugs. The body's
|
||||
`## Depends on` section is human prose and is passed through UNCHANGED in both
|
||||
directions: a pull seeds `depends:` from the `#N` it finds there, and a push
|
||||
never rewrites what the author wrote. Deliberate — a translator that edits
|
||||
prose churns the body on every round trip.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..", "issue", "scripts")))
|
||||
import issue # noqa: E402
|
||||
|
||||
# How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
|
||||
# an issue IS, which is exactly why it lives here and not in the domain.
|
||||
LABEL_COLORS = {
|
||||
"type/bug": "#ee0701",
|
||||
"type/task": "#0e8a16",
|
||||
"type/refactor": "#1d76db",
|
||||
"type/test": "#fbca04",
|
||||
"type/feature": "#5319e7",
|
||||
"type/draft": "#cccccc",
|
||||
"severity/low": "#c2e0c6",
|
||||
"severity/medium": "#fbca04",
|
||||
"severity/high": "#eb6420",
|
||||
"severity/showstopper": "#ee0701",
|
||||
"severity/critical": "#b60205",
|
||||
}
|
||||
DEFAULT_COLOR = "#ededed"
|
||||
|
||||
# What this bridge writes into the domain's `origin:` field. The domain records
|
||||
# that an issue exists somewhere else; only this module knows where.
|
||||
ORIGIN = "gitea"
|
||||
|
||||
|
||||
def label_specs(names):
|
||||
"""{name: {color, description, exclusive}} for the transport to create.
|
||||
|
||||
Exclusivity and meaning come from the domain taxonomy; only the color is
|
||||
decided here. `tea labels create` cannot set `exclusive` (as of 0.14.2),
|
||||
which is why these go through the API."""
|
||||
out = {}
|
||||
for name in names:
|
||||
desc = ""
|
||||
if name.startswith("type/"):
|
||||
desc = issue.TYPES.get(name.split("/", 1)[1], "")
|
||||
out[name] = {
|
||||
"color": LABEL_COLORS.get(name, DEFAULT_COLOR),
|
||||
"description": desc,
|
||||
"exclusive": name.startswith(issue.EXCLUSIVE_NS),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def remote_key(repo, number):
|
||||
"""Stable cross-repo handle: owner/repo#42."""
|
||||
return "%s#%d" % (repo, int(number))
|
||||
|
||||
|
||||
def parse_remote_key(key):
|
||||
repo, _, num = (key or "").rpartition("#")
|
||||
return (repo, int(num)) if repo and num.isdigit() else (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gitea -> domain
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def numbers_in_body(body):
|
||||
"""`#N` referenced from the body's dependency sections, as ints. Used only
|
||||
to seed `depends:` on the first pull."""
|
||||
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
|
||||
|
||||
|
||||
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None):
|
||||
"""Build a domain Issue from a Gitea issue payload.
|
||||
|
||||
id_for_number maps a Gitea number to a local slug — dependencies whose
|
||||
target has not been pulled yet are dropped from `depends:` (the body still
|
||||
names them, so nothing is lost) rather than invented."""
|
||||
body = (payload.get("body") or "").strip()
|
||||
id_for_number = id_for_number or {}
|
||||
|
||||
numbers = list(numbers_in_body(body))
|
||||
for n in extra_numbers:
|
||||
if n not in numbers:
|
||||
numbers.append(n)
|
||||
depends, unresolved = [], []
|
||||
for n in numbers:
|
||||
slug = id_for_number.get(n)
|
||||
if slug and slug != id and slug not in depends:
|
||||
depends.append(slug)
|
||||
elif not slug:
|
||||
unresolved.append(n)
|
||||
|
||||
extra = {
|
||||
"gitea": remote_key(repo, payload["number"]),
|
||||
"url": payload.get("html_url", ""),
|
||||
"synced": synced or "",
|
||||
}
|
||||
if payload.get("updated_at"):
|
||||
extra["remote-updated"] = payload["updated_at"]
|
||||
if payload.get("comments"):
|
||||
extra["comments"] = payload["comments"]
|
||||
|
||||
iss = issue.Issue(
|
||||
id=id,
|
||||
title=payload.get("title", ""),
|
||||
body=body,
|
||||
state=payload.get("state") or "open",
|
||||
labels=[l.get("name", "") for l in payload.get("labels") or []],
|
||||
assignees=[a.get("login", "") for a in payload.get("assignees") or []],
|
||||
milestone=(payload.get("milestone") or {}).get("title") or "",
|
||||
depends=depends,
|
||||
origin=ORIGIN,
|
||||
extra=extra)
|
||||
return iss, unresolved
|
||||
|
||||
|
||||
def render_comments(comments):
|
||||
"""Comment thread as flat markdown. Read-only: nothing writes it back."""
|
||||
out = []
|
||||
for c in comments:
|
||||
out.append("## comment %s — %s — %s" % (
|
||||
c.get("id"), (c.get("user") or {}).get("login", ""),
|
||||
(c.get("created_at") or "")[:10]))
|
||||
out.append("")
|
||||
out.append((c.get("body") or "(empty)").strip())
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# domain -> Gitea
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
|
||||
"""Request body for POST /issues or PATCH /issues/{n}.
|
||||
|
||||
The body is sent verbatim — see the module docstring on why slugs in
|
||||
`## Depends on` are not rewritten to `#N`."""
|
||||
payload = {"title": iss.title, "body": iss.body.strip()}
|
||||
if label_ids is not None:
|
||||
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
|
||||
if iss.assignees:
|
||||
payload["assignees"] = list(iss.assignees)
|
||||
if milestone_id is not None:
|
||||
payload["milestone"] = milestone_id
|
||||
if include_state:
|
||||
payload["state"] = iss.state
|
||||
return payload
|
||||
|
||||
|
||||
def apply_remote(iss, payload, repo, synced):
|
||||
"""Stamp the sync-owned fields onto an issue after a successful write.
|
||||
Mutates and returns it; `origin` is the one domain field this touches."""
|
||||
iss.origin = ORIGIN
|
||||
iss.extra["gitea"] = remote_key(repo, payload["number"])
|
||||
iss.extra["url"] = payload.get("html_url", "")
|
||||
iss.extra["synced"] = synced
|
||||
if payload.get("updated_at"):
|
||||
iss.extra["remote-updated"] = payload["updated_at"]
|
||||
return iss
|
||||
|
||||
|
||||
def number_of(iss):
|
||||
"""Gitea number for an already-synced issue, or None."""
|
||||
_repo, n = parse_remote_key(iss.extra.get("gitea", ""))
|
||||
return n
|
||||
@@ -1,203 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pull.py — Gitea issues -> the local store.
|
||||
|
||||
Writes flat markdown the domain layer owns and prints a compact index; the raw
|
||||
API payload never reaches the conversation. An issue already in the store keeps
|
||||
its slug even when its title changes on the server — identity is the local id,
|
||||
matched through tmp/issues/.remote.json (and recoverable from the `gitea:`
|
||||
fields if that file is lost).
|
||||
|
||||
Two ways to name what to pull:
|
||||
|
||||
pull.py 42 [17 …] by key: 42 | #42 | owner/repo#42 | URL
|
||||
pull.py --milestone 6 by filter: whole milestone in ONE request
|
||||
pull.py --label type/bug --state all
|
||||
pull.py -q sqlc --limit 20
|
||||
|
||||
Filter mode costs one request per 50 issues — the list payload already carries
|
||||
the bodies. Gitea silently ignores an unresolvable `milestones=` filter and
|
||||
returns the whole backlog, so the milestone is resolved up front and every
|
||||
issue is re-checked locally. Projects are NOT filterable: the projects API is
|
||||
not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI.
|
||||
|
||||
Other flags:
|
||||
--deps [--depth N] follow dependencies and pull them too
|
||||
--comments also fetch comments (single issue only)
|
||||
--cached skip issues already on disk instead of refetching
|
||||
--repo owner/repo default: auto-detect from the CWD git remote
|
||||
|
||||
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
|
||||
have not pushed are lost. Draw the graph afterwards with the domain's own
|
||||
issue_tree.py — it needs no network.
|
||||
|
||||
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_index # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def id_for(payload, store_ids, remote_map, repo, root):
|
||||
"""Existing slug for this remote issue, or a fresh unique one. A retitled
|
||||
issue keeps the slug it was first pulled under — the map is by number."""
|
||||
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
|
||||
if got:
|
||||
return got
|
||||
return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Pull Gitea issues into the local store")
|
||||
ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL")
|
||||
ap.add_argument("--milestone", help="pull a whole milestone (id or title)")
|
||||
ap.add_argument("--label", action="append", default=[],
|
||||
help="filter by label; repeat for AND")
|
||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
|
||||
help="filter mode only (default: open)")
|
||||
ap.add_argument("--limit", type=int, default=100, help="filter mode cap (default: 100)")
|
||||
ap.add_argument("--deps", action="store_true", help="follow dependencies and pull them")
|
||||
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
|
||||
ap.add_argument("--comments", action="store_true",
|
||||
help="also fetch comments (single issue only)")
|
||||
ap.add_argument("--cached", action="store_true",
|
||||
help="skip issues already on disk instead of refetching")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
filtered = bool(args.milestone or args.label or args.query)
|
||||
if args.keys and filtered:
|
||||
_gitea.die("pass issue keys OR filters, not both")
|
||||
if not args.keys and not filtered:
|
||||
_gitea.die("nothing to pull: pass issue keys, or --milestone / --label / -q")
|
||||
|
||||
root = args.out
|
||||
login = _gitea.require_login()
|
||||
|
||||
# ---- which repo ------------------------------------------------------
|
||||
repo_arg = args.repo
|
||||
if not repo_arg and args.keys:
|
||||
repos = {_gitea.parse_key(k)[1] for k in args.keys} - {None}
|
||||
if len(repos) > 1:
|
||||
_gitea.die("all keys must belong to one repo, got: %s" % ", ".join(sorted(repos)))
|
||||
repo_arg = repos.pop() if repos else None
|
||||
base = _gitea.repo_base(repo_arg)
|
||||
repo = _gitea.repo_slug(login, repo_arg)
|
||||
|
||||
issues = issue.load_all(root)
|
||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
||||
store_ids = set(issues)
|
||||
number_of_id = {gmap.parse_remote_key(k)[1]: v for k, v in remote_map.items()
|
||||
if gmap.parse_remote_key(k)[0] == repo}
|
||||
|
||||
written, skipped, pending = [], [], []
|
||||
|
||||
# ---- seeds -----------------------------------------------------------
|
||||
if filtered:
|
||||
payloads, ms_title = _gitea.list_issues(
|
||||
login, base, state=args.state, labels=args.label, query=args.query,
|
||||
milestone=args.milestone, limit=args.limit)
|
||||
if not payloads:
|
||||
_gitea.die("no issues match that filter")
|
||||
what = []
|
||||
if args.milestone:
|
||||
what.append("milestone %s" % ms_title)
|
||||
what += ["label %s" % l for l in args.label]
|
||||
if args.query:
|
||||
what.append("q=%r" % args.query)
|
||||
sys.stderr.write("%d issue(s) match %s (%s)\n"
|
||||
% (len(payloads), " + ".join(what), args.state))
|
||||
queue = [(p, 0) for p in payloads]
|
||||
seen_numbers = {p["number"] for p in payloads}
|
||||
else:
|
||||
numbers = [_gitea.parse_key(k)[0] for k in args.keys]
|
||||
queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers]
|
||||
seen_numbers = set(numbers)
|
||||
|
||||
if args.comments and len(queue) > 1:
|
||||
_gitea.die("--comments works on a single issue; loop over the numbers instead")
|
||||
|
||||
# ---- walk ------------------------------------------------------------
|
||||
while queue:
|
||||
payload, depth = queue.pop(0)
|
||||
number = payload["number"]
|
||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||
store_ids.add(id)
|
||||
number_of_id[number] = id
|
||||
|
||||
if args.cached and os.path.isfile(issue.path_of(root, id)):
|
||||
skipped.append(id)
|
||||
else:
|
||||
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||
id_for_number=number_of_id,
|
||||
extra_numbers=extra,
|
||||
synced=_gitea.now_iso())
|
||||
issue.save(root, iss)
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
written.append(id)
|
||||
pending.append((id, unresolved))
|
||||
|
||||
if args.deps and depth < args.depth:
|
||||
child_numbers = (gmap.numbers_in_body(payload.get("body") or "")
|
||||
+ _gitea.native_deps(login, base, number))
|
||||
for n in child_numbers:
|
||||
if n in seen_numbers:
|
||||
continue
|
||||
seen_numbers.add(n)
|
||||
queue.append((_gitea.get_issue(login, base, n), depth + 1))
|
||||
|
||||
# ---- second pass: dependencies that were not yet known on first write --
|
||||
for id, unresolved in pending:
|
||||
newly = [number_of_id[n] for n in unresolved
|
||||
if n in number_of_id and number_of_id[n] != id]
|
||||
if not newly:
|
||||
continue
|
||||
iss = issue.load(root, id)
|
||||
for slug in newly:
|
||||
if slug not in iss.depends:
|
||||
iss.depends.append(slug)
|
||||
issue.save(root, iss)
|
||||
|
||||
cpath = None
|
||||
if args.comments:
|
||||
id = written[0] if written else skipped[0]
|
||||
_repo, number = gmap.parse_remote_key(issue.load(root, id).extra.get("gitea", ""))
|
||||
comments = _gitea.get_comments(login, base, number)
|
||||
cpath = os.path.join(root, "%s.comments.md" % id)
|
||||
if comments:
|
||||
with open(cpath, "w") as f:
|
||||
f.write(gmap.render_comments(comments))
|
||||
else:
|
||||
if os.path.isfile(cpath):
|
||||
os.remove(cpath) # stale file from an earlier pull
|
||||
cpath = None
|
||||
|
||||
_gitea.save_map(root, remote_map)
|
||||
index_path, _ = issue_index.build(root)
|
||||
|
||||
# Compact output — the only thing that lands in the model's context.
|
||||
for id in sorted(set(written) | set(skipped)):
|
||||
iss = issue.load(root, id)
|
||||
print("%s [%s] %s — %s %s%s" % (
|
||||
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
||||
issue.path_of(root, id), " (cached)" if id in skipped else ""))
|
||||
if cpath:
|
||||
print("comments: %s" % cpath)
|
||||
print("index: %s" % index_path)
|
||||
if args.deps:
|
||||
print("graph: run issue_tree.py (offline) to draw it")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
push.py — local store -> Gitea.
|
||||
|
||||
Pushing is additive. The local file is never deleted and never moves: it gains
|
||||
`gitea:`, `url:` and `synced:`, and `origin:` flips from `local` to `gitea`.
|
||||
One issue, two places it is visible — not two kinds of file. A local-only issue
|
||||
is a finished state, not a step on the way to a tracker.
|
||||
|
||||
push.py every local-only issue, dependencies first
|
||||
push.py wire-sqlc-appclick one issue
|
||||
push.py --update <id …> PATCH issues that are already in Gitea
|
||||
push.py --dry-run validate only, no network
|
||||
|
||||
Before anything is sent, each issue is validated against the canonical format
|
||||
by the domain layer (exactly one type/*, English title with no type prefix,
|
||||
`## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts
|
||||
anyway; say why when you use it.
|
||||
|
||||
Dependencies are pushed in topological order so a parent is created after the
|
||||
issues it depends on. A dependency that is still local-only is reported, not
|
||||
silently dropped — the body's `## Depends on` prose is sent verbatim either
|
||||
way, so nothing is lost, but the `#N` cross-links will be missing.
|
||||
|
||||
Missing labels are created with the canonical color and, for type/* and
|
||||
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
||||
|
||||
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_index # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def select(issues, ids, update):
|
||||
"""Which issues to send, and refuse the ambiguous combinations."""
|
||||
if ids:
|
||||
missing = [i for i in ids if i not in issues]
|
||||
if missing:
|
||||
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
||||
chosen = list(ids)
|
||||
else:
|
||||
chosen = sorted(i for i in issues
|
||||
if update or not issues[i].extra.get("gitea"))
|
||||
if not chosen:
|
||||
_gitea.die("nothing to push: every issue in the store is already in Gitea "
|
||||
"(use --update to PATCH them, or issue_new.py to make one)")
|
||||
if not update:
|
||||
already = [i for i in chosen if issues[i].extra.get("gitea")]
|
||||
if already:
|
||||
_gitea.die("already in Gitea: %s — pass --update to PATCH them"
|
||||
% ", ".join(already))
|
||||
return chosen
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Push local issues to Gitea")
|
||||
ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)")
|
||||
ap.add_argument("--update", action="store_true",
|
||||
help="PATCH issues that already carry a gitea: field")
|
||||
ap.add_argument("--dry-run", action="store_true", help="validate only, no network")
|
||||
ap.add_argument("--force", action="store_true", help="push despite format violations")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = args.out
|
||||
issues = issue.load_all(root)
|
||||
if not issues:
|
||||
_gitea.die("store %s is empty — create an issue with issue_new.py first" % root)
|
||||
|
||||
chosen = select(issues, args.ids, args.update)
|
||||
|
||||
# ---- validate (domain layer, no network) -----------------------------
|
||||
known = set(issues)
|
||||
blocked = False
|
||||
for id in chosen:
|
||||
err, warn = issue.validate(issues[id], known_ids=known)
|
||||
for w in warn:
|
||||
_gitea.warn("%s: %s" % (id, w))
|
||||
for e in err:
|
||||
sys.stderr.write("%s: %s\n" % (id, e))
|
||||
if err:
|
||||
blocked = True
|
||||
if blocked and not args.force:
|
||||
_gitea.die("format violations (see above); --force overrides")
|
||||
|
||||
# ---- dependencies first ----------------------------------------------
|
||||
edges = {i: [d for d in issues[i].depends if d in issues] for i in chosen}
|
||||
order = [i for i in issue.topo_order(chosen, edges) if i in set(chosen)]
|
||||
for c in issue.find_cycles(edges):
|
||||
_gitea.warn("dependency cycle: %s" % " -> ".join(c))
|
||||
|
||||
if args.dry_run:
|
||||
for id in order:
|
||||
iss = issues[id]
|
||||
print("ok %s [type/%s] %s (%s)"
|
||||
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
|
||||
print("%d issue(s) would be %s" % (len(order), "updated" if args.update else "created"))
|
||||
return
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(args.repo)
|
||||
repo = _gitea.repo_slug(login, args.repo)
|
||||
|
||||
wanted = sorted({l for id in order for l in issues[id].labels})
|
||||
label_ids = _gitea.ensure_labels(login, base, gmap.label_specs(wanted), root) \
|
||||
if wanted else {}
|
||||
|
||||
milestone_ids = {}
|
||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
||||
|
||||
for id in order:
|
||||
iss = issues[id]
|
||||
|
||||
unsynced = [d for d in iss.depends
|
||||
if d in issues and not issues[d].extra.get("gitea")
|
||||
and d not in order]
|
||||
if unsynced:
|
||||
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
||||
% (id, ", ".join(unsynced)))
|
||||
|
||||
ms_id = None
|
||||
if iss.milestone:
|
||||
if iss.milestone not in milestone_ids:
|
||||
milestone_ids[iss.milestone] = _gitea.resolve_milestone_id(
|
||||
login, base, iss.milestone)
|
||||
ms_id = milestone_ids[iss.milestone]
|
||||
if ms_id is None:
|
||||
_gitea.warn("%s: milestone %r does not exist in %s — not set"
|
||||
% (id, iss.milestone, repo))
|
||||
|
||||
number = gmap.number_of(iss)
|
||||
if number:
|
||||
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
||||
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH", payload,
|
||||
payload_name="issue-%s" % id, out_root=root)
|
||||
verb = "updated"
|
||||
else:
|
||||
payload = gmap.to_payload(iss, label_ids, ms_id)
|
||||
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
|
||||
payload_name="issue-%s" % id, out_root=root)
|
||||
verb = "created"
|
||||
if not isinstance(got, dict) or "number" not in got:
|
||||
_gitea.die("%s: %s failed, unexpected response" % (id, verb))
|
||||
number = got["number"]
|
||||
|
||||
# Gitea occasionally drops labels on create — re-apply rather than
|
||||
# trust the echo.
|
||||
applied = {l.get("name", "") for l in got.get("labels") or []}
|
||||
missing = [l for l in iss.labels if l in label_ids and l not in applied]
|
||||
if missing:
|
||||
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
|
||||
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
|
||||
payload_name="labels-%s" % id, out_root=root)
|
||||
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
|
||||
|
||||
gmap.apply_remote(iss, got, repo, _gitea.now_iso())
|
||||
issue.save(root, iss)
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
|
||||
|
||||
_gitea.save_map(root, remote_map)
|
||||
path, n = issue_index.build(root)
|
||||
print("index: %s — %d issue(s)" % (path, n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user