Compare commits
62 commits
feat/teleg
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08385c9dd | ||
|
|
7e4dfcc38b | ||
|
|
d16c2434a3 | ||
|
|
ab0c3dd68d | ||
|
|
9dabc57d9a | ||
|
|
0fbf1d97bc | ||
|
|
93e7843438 | ||
|
|
77143ac6da | ||
|
|
9459e47567 | ||
|
|
6bb115e706 | ||
|
|
43975cb593 | ||
|
|
c9198a40a7 | ||
|
|
fa6fc9ecbd | ||
|
|
ca2a04ce47 | ||
|
|
70b8203704 | ||
|
|
c0e8ec816a | ||
|
|
7c3ded601f | ||
|
|
e4f9313641 | ||
|
|
06899b3c01 | ||
|
|
8b05fa74f7 | ||
|
|
13942526a1 | ||
|
|
8e00969c3e | ||
|
|
aa1e5c08ae | ||
|
|
feea1b58d9 | ||
|
|
baf6b30547 | ||
|
|
93911f2af6 | ||
|
|
10df459cee | ||
|
|
596aa9b18a | ||
|
|
1e9ee49a1d | ||
|
|
52343e2a78 | ||
|
|
b3aac737eb | ||
|
|
51a5300423 | ||
|
|
35b6c6e542 | ||
|
|
42c8e2d31e | ||
|
|
cc2c896290 | ||
|
|
7d1ef60f6f | ||
|
|
80f21cdf08 | ||
|
|
b7b1920385 | ||
|
|
1dc2b0fa41 | ||
|
|
a1f18eaf24 | ||
|
|
37ebcd0e08 | ||
|
|
b2329210d5 | ||
|
|
1d35c5ed1c | ||
|
|
9dd462b764 | ||
|
|
0c1e3e7d39 | ||
|
|
85d7d62fcd | ||
|
|
87d6c4464b | ||
|
|
66f0afa58b | ||
|
|
cdd8ba20e3 | ||
|
|
f0701558d3 | ||
|
|
56bd356951 | ||
|
|
6bec709ec2 | ||
|
|
3caa83e617 | ||
|
|
72caab4097 | ||
|
|
8c00346b76 | ||
|
|
0bf97cf1c5 | ||
|
|
e954284ec3 | ||
|
|
c87fcc7958 | ||
|
|
f67a9298cc | ||
|
|
39de98dc95 | ||
|
|
4258621def | ||
|
|
1bc8b8ed05 |
341 changed files with 15378 additions and 3272 deletions
|
|
@ -18,6 +18,10 @@ OPENCODE_SERVER_PASSWORD=your-opencode-server-password
|
||||||
# GitHub
|
# GitHub
|
||||||
GITHUB_TOKEN=your-github-token-here
|
GITHUB_TOKEN=your-github-token-here
|
||||||
|
|
||||||
|
# Forgejo (self-hosted) — used by status oracles with Forgejo backend
|
||||||
|
FORGEJO_URL=https://git.slaid098.dev
|
||||||
|
FORGEJO_TOKEN=your-forgejo-api-token-here
|
||||||
|
|
||||||
# Context7 MCP
|
# Context7 MCP
|
||||||
CONTEXT7_API_KEY=your-context7-api-key-here
|
CONTEXT7_API_KEY=your-context7-api-key-here
|
||||||
|
|
||||||
|
|
@ -26,11 +30,16 @@ TELEGRAM_API_ID=your-telegram-api-id
|
||||||
TELEGRAM_API_HASH=your-telegram-api-hash
|
TELEGRAM_API_HASH=your-telegram-api-hash
|
||||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||||
TELEGRAM_CHAT_ID=your-telegram-chat-id
|
TELEGRAM_CHAT_ID=your-telegram-chat-id
|
||||||
|
# Private Telegram group for Boosty subscribers (builds, configs)
|
||||||
|
TELEGRAM_BOOSTY_CHAT_ID=your-boosty-chat-id
|
||||||
|
|
||||||
# Cloudflare Tunnel (optional — see separate tunnel repo)
|
# Cloudflare Tunnel (optional — see separate tunnel repo)
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=
|
CLOUDFLARE_TUNNEL_TOKEN=
|
||||||
TUNNEL_DOMAIN=
|
TUNNEL_DOMAIN=
|
||||||
|
|
||||||
|
# Vercel (optional — placeholder example, not currently wired to code)
|
||||||
|
VERCEL_TOKEN=
|
||||||
|
|
||||||
# Antidetect Browser MCP
|
# Antidetect Browser MCP
|
||||||
# Local: http://localhost:8765/mcp
|
# Local: http://localhost:8765/mcp
|
||||||
# Remote: http://<your-server-ip>:8765/mcp
|
# Remote: http://<your-server-ip>:8765/mcp
|
||||||
|
|
|
||||||
33
.github/workflows/adr-check.yml
vendored
33
.github/workflows/adr-check.yml
vendored
|
|
@ -1,33 +0,0 @@
|
||||||
name: ADR References Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'docs/**'
|
|
||||||
- '.opencode/**'
|
|
||||||
- '**/*.md'
|
|
||||||
- '.github/workflows/adr-check.yml'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- id: check
|
|
||||||
run: |
|
|
||||||
if [ -f .opencode/scripts/check-adr-refs.py ]; then
|
|
||||||
echo "has_script=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "has_script=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
if: steps.check.outputs.has_script == 'true'
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- run: python3 .opencode/scripts/check-adr-refs.py
|
|
||||||
if: steps.check.outputs.has_script == 'true'
|
|
||||||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
|
|
@ -35,10 +35,6 @@ jobs:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
- run: uv sync --extra dev
|
- run: uv sync --extra dev
|
||||||
- run: uv run ruff check src/ tests/ .opencode/scripts/
|
- run: uv run ruff check src/ tests/ .opencode/scripts/
|
||||||
- run: uv run ruff format --check src/ tests/ .opencode/scripts/
|
- run: uv run ruff format --check src/ tests/ .opencode/scripts/
|
||||||
|
|
@ -50,10 +46,6 @@ jobs:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
- run: uv sync --extra dev
|
- run: uv sync --extra dev
|
||||||
- run: uv run mypy src/
|
- run: uv run mypy src/
|
||||||
|
|
||||||
|
|
@ -65,23 +57,15 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python: ["3.12", "3.13", "3.14"]
|
python: ["3.13"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python }}
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
- run: sudo apt-get update && sudo apt-get install -y ripgrep
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
working-directory: .opencode
|
working-directory: .opencode
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
working-directory: .opencode/draw-image
|
working-directory: .opencode/draw-image
|
||||||
- run: npm test
|
- run: npm test
|
||||||
working-directory: .opencode/draw-image
|
working-directory: .opencode/draw-image
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
- run: uv sync --extra dev --python ${{ matrix.python }}
|
- run: uv sync --extra dev --python ${{ matrix.python }}
|
||||||
- run: uv run --python ${{ matrix.python }} pytest
|
- run: uv run --python ${{ matrix.python }} pytest
|
||||||
|
|
||||||
|
|
@ -92,9 +76,5 @@ jobs:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
- run: uv sync --extra dev
|
- run: uv sync --extra dev
|
||||||
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/
|
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/
|
||||||
|
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
---
|
|
||||||
description: Reviews and updates project map documentation before code review. Auto-commits updates to PR branch. Posts verdict via post-docs-review tool (deterministic heading for pipeline-status.py).
|
|
||||||
mode: subagent
|
|
||||||
temperature: 0.1
|
|
||||||
steps: 150
|
|
||||||
permission:
|
|
||||||
edit: allow
|
|
||||||
doom_loop: deny
|
|
||||||
bash:
|
|
||||||
"*": deny
|
|
||||||
"git fetch*": allow
|
|
||||||
"git diff*": allow
|
|
||||||
"git log*": allow
|
|
||||||
"git status*": allow
|
|
||||||
"git show*": allow
|
|
||||||
"git blame*": allow
|
|
||||||
"git remote -v*": allow
|
|
||||||
"git remote show*": allow
|
|
||||||
"git add docs/project-map*": allow
|
|
||||||
"git add docs/handoff*": allow
|
|
||||||
"git add docs/decisions*": allow
|
|
||||||
"git push*": allow
|
|
||||||
"rg *": allow
|
|
||||||
"find *": allow
|
|
||||||
"ls *": allow
|
|
||||||
"cat *": allow
|
|
||||||
"head*": allow
|
|
||||||
"tail*": allow
|
|
||||||
"wc*": allow
|
|
||||||
"diff*": allow
|
|
||||||
"repomix*": allow
|
|
||||||
"gh pr view*": allow
|
|
||||||
"gh pr diff*": allow
|
|
||||||
"gh pr checkout*": allow
|
|
||||||
"gh pr comment*": allow
|
|
||||||
"gh pr comment *": allow
|
|
||||||
"gh issue view*": allow
|
|
||||||
"gh issue list*": allow
|
|
||||||
"git rm docs/handoff*": allow
|
|
||||||
"git rm docs/decisions*": allow
|
|
||||||
"git rm -r docs/spec*": allow
|
|
||||||
"git rm --cached docs/handoff*": allow
|
|
||||||
"git rm --cached docs/decisions*": allow
|
|
||||||
"git mv docs/handoff*": allow
|
|
||||||
"git mv docs/decisions*": allow
|
|
||||||
"git checkout docs/handoff*": allow
|
|
||||||
"git checkout docs/decisions*": allow
|
|
||||||
"git -C * status*": allow
|
|
||||||
"git -C * diff*": allow
|
|
||||||
"git -C * log*": allow
|
|
||||||
"git -C * show*": allow
|
|
||||||
"git -C * branch*": allow
|
|
||||||
"git -C * fetch*": allow
|
|
||||||
"git -C * remote -v*": allow
|
|
||||||
"git -C * remote show*": allow
|
|
||||||
"git branch*": allow
|
|
||||||
"pwd": allow
|
|
||||||
"echo *": allow
|
|
||||||
"python3*": allow
|
|
||||||
"python3 *pipeline-status.py*": deny
|
|
||||||
"python3 .opencode/scripts/pipeline-status.py*": deny
|
|
||||||
"python3 */pipeline-status.py*": deny
|
|
||||||
"python *pipeline-status.py*": deny
|
|
||||||
"python */pipeline-status.py*": deny
|
|
||||||
"python3 *spec-status.py*": deny
|
|
||||||
"python3 .opencode/scripts/spec-status.py*": deny
|
|
||||||
"python3 */spec-status.py*": deny
|
|
||||||
"python *spec-status.py*": deny
|
|
||||||
"python */spec-status.py*": deny
|
|
||||||
"mkdir*": allow
|
|
||||||
"gh pr merge*": deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a project map reviewer. Your job: analyze structural changes in a PR, update the project map documentation in `docs/project-map/`, and commit updates to the PR branch.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Run `gh pr view <PR_NUMBER> --json headRefName,title,body` to get branch name and PR context.
|
|
||||||
2. Run `gh pr checkout <PR_NUMBER>` to switch to the PR branch.
|
|
||||||
3. Run `git fetch origin` to ensure you have the latest default branch.
|
|
||||||
4. Run `git diff origin/HEAD...HEAD --stat` to see what files changed.
|
|
||||||
5. Run `repomix --no-files --stdout` to get the current directory tree.
|
|
||||||
6. Check if `docs/project-map/` exists:
|
|
||||||
- Run `ls docs/project-map/ 2>/dev/null`
|
|
||||||
- If it doesn't exist, create the directory and an initial `README.md`.
|
|
||||||
|
|
||||||
## Analysis
|
|
||||||
|
|
||||||
1. Compare the `git diff --stat` output with the current `docs/project-map/` files.
|
|
||||||
2. Determine if structural changes occurred:
|
|
||||||
- New files or directories added
|
|
||||||
- Files or directories deleted
|
|
||||||
- Files or directories renamed
|
|
||||||
- New top-level modules
|
|
||||||
3. If NO structural changes (only content edits, bug fixes, refactoring within existing files) → skip project-map update, but STILL leave PR comment per "PR Comment (mandatory)" section below.
|
|
||||||
4. If structural changes occurred → proceed to update.
|
|
||||||
|
|
||||||
## Handoff & ADR Validation
|
|
||||||
|
|
||||||
After updating project map, validate handoff and ADR files:
|
|
||||||
|
|
||||||
### Handoff (`docs/handoff/pr-<PR_NUMBER>-<slug>.md`)
|
|
||||||
1. Check if file exists. If not → create it from PR diff.
|
|
||||||
2. Check all sections are present: Что сделано, Почему, Pending, Watch out.
|
|
||||||
3. Check content is meaningful (not empty placeholders).
|
|
||||||
4. If sections missing or empty → **fix them** based on PR diff and issue context.
|
|
||||||
|
|
||||||
### ADR (`docs/decisions/<NN>-<title>.md`) — mandatory per ADR-002
|
|
||||||
|
|
||||||
1. ADR is **mandatory** in every PR (per ADR-002). Never bypass.
|
|
||||||
2. If ADR file `docs/decisions/*-pr-<PR#>-*.md` does not exist → create it via `bash .opencode/scripts/scaffold-handoff.sh <PR#> <slug>` (creates both handoff + ADR templates).
|
|
||||||
3. Check ADR sections: Статус, Контекст, Решение, Альтернативы.
|
|
||||||
4. If sections incomplete (empty placeholders like `<заполни>`) → **fix them** based on PR diff.
|
|
||||||
5. If PR has NO architectural decisions → fill all sections (Контекст/Решение/Альтернативы) with `—` (em-dash). This is valid per ADR-002.
|
|
||||||
6. NEVER bypass ADR creation. Pipeline-status.py will fail DOCS phase if ADR is missing.
|
|
||||||
|
|
||||||
## Spec cleanup (post-merge, опционально)
|
|
||||||
|
|
||||||
**GUARD: НЕ выполнять spec cleanup pre-merge — это post-merge операция.**
|
|
||||||
Spec cleanup выполняется ТОЛЬКО post-merge (в MEMORY фазе), НЕ pre-merge.
|
|
||||||
Если ты docs-reviewer (pre-merge), пропусти эту секцию — spec cleanup
|
|
||||||
выполняет memory-syncer post-merge.
|
|
||||||
|
|
||||||
Если `docs/spec/roadmap.md` существует в репо (spec был запущен):
|
|
||||||
1. Извлеки все `#N` номера issues из `docs/spec/roadmap.md` (regex `#(\d+)`).
|
|
||||||
2. Для каждого `#N`: `gh issue view N --json state --jq .state`.
|
|
||||||
3. Если ВСЕ issues имеют `state=CLOSED`:
|
|
||||||
- `git rm -r docs/spec/` (удаляет всю директорию spec-документации).
|
|
||||||
- Коммит через `commit` tool: `commit({ message: "chore: remove completed spec" })`.
|
|
||||||
- PR comment: добавить секцию `## Spec Cleanup` в docs-review summary: "Spec removed: all N issues from roadmap.md are CLOSED".
|
|
||||||
4. Если хотя бы один issue OPEN → пропусти cleanup (spec ещё жив). PR comment: "Spec retained: M/N issues still OPEN".
|
|
||||||
|
|
||||||
Проверка выполняется ПОСЛЕ валидации handoff/ADR и ДО `git add docs/project-map/ docs/handoff/ docs/decisions/`.
|
|
||||||
|
|
||||||
Используется `git rm -r docs/spec/` (НЕ `rm -rf docs/spec/`) — `rm -rf` блокируется `check-permissions.py` (DANGEROUS_PATTERNS, scope=all). `git rm -r` семантически эквивалентен и соответствует существующим паттернам `git rm docs/handoff*` / `git rm docs/decisions*`.
|
|
||||||
|
|
||||||
### Commit scope
|
|
||||||
When committing, stage docs first, then use the `commit` tool (raw `git commit` is globally denied — use the tool which bypasses via spawnSync):
|
|
||||||
```bash
|
|
||||||
git add docs/project-map/ docs/handoff/ docs/decisions/
|
|
||||||
```
|
|
||||||
```
|
|
||||||
commit({ message: "docs: update project map + handoff + ADR" })
|
|
||||||
```
|
|
||||||
```bash
|
|
||||||
git push
|
|
||||||
```
|
|
||||||
|
|
||||||
## Update Rules
|
|
||||||
|
|
||||||
### What to include in project map:
|
|
||||||
- Directory structure (tree of each module)
|
|
||||||
- Purpose of each module/directory
|
|
||||||
- Key files and their roles
|
|
||||||
- Dependencies between modules
|
|
||||||
|
|
||||||
### What NOT to include:
|
|
||||||
- Implementation details
|
|
||||||
- API signatures
|
|
||||||
- Internal logic
|
|
||||||
- Line-by-line documentation
|
|
||||||
|
|
||||||
### File structure:
|
|
||||||
- `docs/project-map/README.md` — index, overall project structure, module list
|
|
||||||
- `docs/project-map/<module>.md` — one file per top-level module/directory
|
|
||||||
|
|
||||||
### MD file template:
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
module: <module-path>
|
|
||||||
purpose: <one-line description>
|
|
||||||
key_files:
|
|
||||||
- <path> — <role>
|
|
||||||
- <path> — <role>
|
|
||||||
dependencies: [<list of module dependencies>]
|
|
||||||
last_updated: <YYYY-MM-DD>
|
|
||||||
---
|
|
||||||
|
|
||||||
# <module name>
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
- `<file>` — <description>
|
|
||||||
- `<file>` — <description>
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
- <pattern or convention used>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
|
|
||||||
1. Stage only project map files:
|
|
||||||
```bash
|
|
||||||
git add docs/project-map/ docs/handoff/ docs/decisions/
|
|
||||||
```
|
|
||||||
2. Commit via `commit` tool (raw `git commit` is globally denied — the tool bypasses via spawnSync):
|
|
||||||
```
|
|
||||||
commit({ message: "docs(project-map): update after structural changes" })
|
|
||||||
```
|
|
||||||
3. Push:
|
|
||||||
```bash
|
|
||||||
git push
|
|
||||||
```
|
|
||||||
|
|
||||||
## PR Comment (mandatory)
|
|
||||||
|
|
||||||
After validation (regardless of whether structural changes occurred), **ALWAYS** leave a PR comment using the `post-docs-review` tool. The tool auto-generates the `## Docs Review Summary` heading and the `### Verdict: <verdict>` line — you only pass the body content (between heading and verdict). Do NOT manually format the heading or verdict. This is the deterministic marker that `check_docs` in pipeline-status.py uses to prove docs-reviewer ran. Without this comment, the pipeline is blocked at DOCS phase.
|
|
||||||
|
|
||||||
Body format (without heading — tool adds `## Docs Review Summary` and `### Verdict: <verdict>`):
|
|
||||||
```
|
|
||||||
- Project map: <updated|no structural changes|created>
|
|
||||||
- Handoff: <valid|fixed: ...|missing: ...>
|
|
||||||
- ADR: <valid|fixed: ...|missing: ...>
|
|
||||||
|
|
||||||
## Spec Cleanup
|
|
||||||
- Spec: <removed: all N issues from roadmap.md are CLOSED|retained: M/N issues still OPEN|n/a: docs/spec/roadmap.md does not exist>
|
|
||||||
```
|
|
||||||
|
|
||||||
Call:
|
|
||||||
```
|
|
||||||
post-docs-review({ pr_number: <PR_NUMBER>, verdict: "<APPROVE|FIXED|NO_CHANGES>", body: `<body text above>` })
|
|
||||||
```
|
|
||||||
|
|
||||||
Verdict semantics:
|
|
||||||
- `APPROVE` — docs valid, no fixes required
|
|
||||||
- `FIXED` — docs-reviewer fixed something (project map, handoff, ADR, or spec cleanup performed)
|
|
||||||
- `NO_CHANGES` — no structural changes, handoff+ADR already valid, spec retained or absent (no commit, no edits)
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. Comment is left AFTER commit+push (if any) — so reviewer can see final state.
|
|
||||||
2. If no structural changes AND handoff+ADR valid → no commit, but comment IS still left with `Verdict: NO_CHANGES`.
|
|
||||||
3. The comment heading `## Docs Review Summary` is guaranteed by the `post-docs-review` tool — `check_docs` matches regex `Docs Review` (case-insensitive).
|
|
||||||
4. Never skip the comment, even on edge cases — use `Verdict: NO_CHANGES` instead of silence.
|
|
||||||
|
|
||||||
## Tool failure handling
|
|
||||||
|
|
||||||
If `post-docs-review` returns a string starting with `⚠️ ...failed` (e.g. `⚠️ post-docs-review failed for PR #N (exit 1): ...`):
|
|
||||||
- **СООБЩИ оркестратору о сбое tool и STOP.** Не продолжай молча, не пытайся fallback на raw `gh pr comment` через bash.
|
|
||||||
- Причина сбоя обычно: gh не аутентифицирован, PR не найден в текущем репо (cwd не git-репо или нет origin remote), или network error.
|
|
||||||
- Возвращай текст вида: `⚠️ post-docs-review tool failed: <сообщение от tool>. Pipeline заблокирован на DOCS phase — требуется вмешательство.`
|
|
||||||
- Любой дальнейший tool call после сбоя = protocol violation.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. ALWAYS checkout the PR branch first.
|
|
||||||
2. ONLY edit files in `docs/project-map/`, `docs/handoff/`, `docs/decisions/`.
|
|
||||||
3. ONLY `git add docs/project-map/ docs/handoff/ docs/decisions/` — never stage other files.
|
|
||||||
4. If no structural changes → do not commit, but STILL leave PR comment (see "PR Comment (mandatory)" section).
|
|
||||||
5. Keep map files concise — structure and purpose, not implementation.
|
|
||||||
6. Update `last_updated` field in frontmatter when modifying a file.
|
|
||||||
7. If `docs/project-map/` doesn't exist → create initial map with `README.md` and one file per top-level module.
|
|
||||||
8. Для debug-вывода используй `pwd`/`ls`/`cat`/`echo` (все в allow-list).
|
|
||||||
9. НЕ переключайся на master и НЕ делай `git pull` — работай только на PR branch (checkout уже сделан в Setup).
|
|
||||||
|
|
||||||
## Bug Discovery
|
|
||||||
|
|
||||||
If you find a bug outside the current PR/task scope — you MUST load skill `bug-discovery` via `skill("bug-discovery")` tool and follow its protocol. Do NOT fix the bug yourself. Report to orchestrator: "Created issue #N: ...".
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
description: Distills durable knowledge from merged PR handoffs into global memory. Read-only on repo, write-only on memory.
|
description: Distills durable knowledge from merged PRs into global memory. Read-only on repo, write-only on memory.
|
||||||
mode: subagent
|
mode: subagent
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
steps: 150
|
steps: 150
|
||||||
|
|
@ -41,7 +41,7 @@ You are **read-only on the repository** and **write-only on memory**. You CANNOT
|
||||||
|
|
||||||
1. Load the memory skill via `skill("memory")` to get distillation rules and format conventions.
|
1. Load the memory skill via `skill("memory")` to get distillation rules and format conventions.
|
||||||
2. Get the PR number from the invocation prompt.
|
2. Get the PR number from the invocation prompt.
|
||||||
3. Find the merged handoff file: `ls docs/handoff/pr-<N>-*` to discover the slug, then `cat docs/handoff/pr-<N>-<slug>.md` to read it (read-only — agent does not check out branches).
|
3. Прочитай PR body через `gh pr view <N> --json body,title` (PR body содержит 4 секции: Что сделано, Почему, Watch out, Pending). При необходимости — `gh issue view <issue-N>` для контекста.
|
||||||
4. Determine the repo: `git remote get-url origin` → parse `{host}/{org}/{repo}` (e.g. `github.com/slaid098/opencode-config`).
|
4. Determine the repo: `git remote get-url origin` → parse `{host}/{org}/{repo}` (e.g. `github.com/slaid098/opencode-config`).
|
||||||
5. Resolve memory path: read `OPENCODE_MEMORY_DIR` env var (set globally via docker-compose; fallback `~/.local/share/opencode/opencode-memory/`) → `<memory_dir>/repos/{host}/{org}/{repo}.md`. Use `printenv OPENCODE_MEMORY_DIR` to inspect it.
|
5. Resolve memory path: read `OPENCODE_MEMORY_DIR` env var (set globally via docker-compose; fallback `~/.local/share/opencode/opencode-memory/`) → `<memory_dir>/repos/{host}/{org}/{repo}.md`. Use `printenv OPENCODE_MEMORY_DIR` to inspect it.
|
||||||
6. Open the memory file (create if missing) via the `edit`/`write` tool — `edit: allow` permits this. The memory dir is an isolated git repo (post-commit hook auto-pushes), separate from the main repo.
|
6. Open the memory file (create if missing) via the `edit`/`write` tool — `edit: allow` permits this. The memory dir is an isolated git repo (post-commit hook auto-pushes), separate from the main repo.
|
||||||
|
|
@ -55,7 +55,7 @@ Durable (записывай):
|
||||||
- patterns, repository conventions
|
- patterns, repository conventions
|
||||||
- pointers: «for X use Y, careful with Z»
|
- pointers: «for X use Y, careful with Z»
|
||||||
- root causes of bugs
|
- root causes of bugs
|
||||||
- ADR pointers: `- [date, PR#N] ADR-NN: <суть> → docs/decisions/NN-title.md` (do NOT copy ADR content — only the pointer)
|
- ADR pointers (исторические, для новых PR): `[date, PR#N] <architectural decision summary>` (без ADR-NN — старые ADR-NNN references в памяти остаются как исторические)
|
||||||
|
|
||||||
НЕ durable (НЕ записывай):
|
НЕ durable (НЕ записывай):
|
||||||
- статусы, «сейчас работаем над», текущие таски, ephemeral контекст
|
- статусы, «сейчас работаем над», текущие таски, ephemeral контекст
|
||||||
|
|
@ -80,24 +80,31 @@ Even if there are no durable records, the receipt is mandatory:
|
||||||
|
|
||||||
This confirms the memory-sync phase was executed (audit trail).
|
This confirms the memory-sync phase was executed (audit trail).
|
||||||
|
|
||||||
### Дедуп перед записью (ОБЯЗАТЕЛЬНО)
|
### Выбор файла для записи (ОБЯЗАТЕЛЬНО перед записью)
|
||||||
|
|
||||||
Перед добавлением записи — прочитай существующий файл. Если похожая запись уже есть (та же гоча/паттерн/root cause) → обнови существующую (bump `updated` в frontmatter, дополни детали если нужно), НЕ добавляй новую. Дубликаты раздули файлы до 600+ KB.
|
Память репо — один или несколько файлов по пути `<memory_dir>/repos/{host}/{org}/{repo}*.md`:
|
||||||
|
- первый файл: `{repo}.md`
|
||||||
|
- последующие (когда первый заморожен по размеру): `{repo}-002.md`, `{repo}-003.md`, ... (3-значный sequential, не по дате)
|
||||||
|
|
||||||
Пример: если «ffmpeg drawbox не поддерживает W/H» уже записан в PR#50 — не добавляй новую запись в PR#120 с той же гочей. Обнови `updated` и допиши нюанс если он есть.
|
Порог ротации: **50 KB** (soft). Файл с размером ≥ 50 KB считается замороженным — новые записи в него НЕ пишутся.
|
||||||
|
|
||||||
## Compaction
|
Перед записью:
|
||||||
|
1. Найди все файлы репо: `ls -la <memory_dir>/repos/{host}/{org}/{repo}*.md` (или `find`).
|
||||||
|
2. Активный файл = **первый существующий** с размером < 50 KB (не последний созданный, а первый по имени — `{repo}.md`, затем `{repo}-002.md` ...). Проверяй размер через `ls -la` (или `wc -c`).
|
||||||
|
3. Если все существующие файлы ≥ 50 KB → создай следующий по sequential нумерации: `{repo}-NNN.md` где NNN — следующий свободный номер (3-значный: 002, 003, 004...). Скопируй frontmatter из предыдущего файла с обновлёнными `created` (текущая дата) и `updated` (текущая дата); `summary` можно сузить под содержимое нового файла; `tags`/`importance`/`related` — без изменений.
|
||||||
|
4. Новый репо без файлов → создай первый `{repo}.md` с нуля (текущее поведение).
|
||||||
|
|
||||||
Если после записи файл > 100 KB → компрессировать:
|
### Дедуп across files (ОБЯЗАТЕЛЬНО перед записью)
|
||||||
1. Прочитай все старые записи
|
|
||||||
2. Оставь только durable (gotchas, паттерны, root causes, ADR-указатели)
|
|
||||||
3. Выкинь не-durable (changelog-дампы «PR#N: добавили X», статусы, хроника событий, receipts с повторяющимся содержанием, метрики PR)
|
|
||||||
4. Объедини дубликаты (одна гоча → одна запись, bump `updated`)
|
|
||||||
5. Tags-строку усечь до < 500 символов (оставить самые релевантные теги)
|
|
||||||
6. Summary усечь до разумного размера (< 500 символов)
|
|
||||||
7. Цель — держать файл < 100 KB
|
|
||||||
|
|
||||||
Критерий выкидывания: «поможет ли это в следующий раз когда я полезу в этот код?» Нет → выкидывай.
|
Перед добавлением новой записи — ищи дубликат по **всем** `repo*.md` (включая замороженные ≥ 50 KB):
|
||||||
|
1. `rg "<ключевая фраза гочи/паттерна>" <memory_dir>/repos/{host}/{org}/{repo}*.md` — ripgrep рекурсивно по всем файлам репо.
|
||||||
|
2. Если похожая запись найдена в **любом** файле (включая замороженный) → обнови её **in-place там же** (bump `updated` в frontmatter того файла, дополни детали если нужно), НЕ добавляй новую в активный файл.
|
||||||
|
3. Новые записи (не найденные как дубликат) → только в активный файл (см. выбор файла выше).
|
||||||
|
4. Замороженные файлы редактируемы для dedup (обновление существующих строк, bump `updated` в их frontmatter). Новые записи в замороженный файл — НЕ пишутся.
|
||||||
|
|
||||||
|
Дубликаты раздули файлы до 600+ KB. Каждая гоча/паттерн/root cause = одна запись в одном файле, не по одной на каждый PR где упоминалась.
|
||||||
|
|
||||||
|
Пример: если «ffmpeg drawbox не поддерживает W/H» уже записан в `youtube-soft.md` (≥ 50 KB, заморожен) в PR#50 — не добавляй новую запись в `youtube-soft-002.md` в PR#120 с той же гочей. Открой `youtube-soft.md`, обнови существующую строку, bump `updated` в frontmatter `youtube-soft.md`, допиши нюанс если он есть.
|
||||||
|
|
||||||
## Save
|
## Save
|
||||||
|
|
||||||
|
|
@ -108,8 +115,8 @@ This confirms the memory-sync phase was executed (audit trail).
|
||||||
|
|
||||||
1. NEVER call `git push`, `git commit`, `git add` — they are not in the allow-list and will be denied by the catch-all rule.
|
1. NEVER call `git push`, `git commit`, `git add` — they are not in the allow-list and will be denied by the catch-all rule.
|
||||||
2. NEVER checkout branches or pull — you operate on the current state of the default branch (already merged).
|
2. NEVER checkout branches or pull — you operate on the current state of the default branch (already merged).
|
||||||
3. ONLY edit files under `<memory_dir>/repos/{host}/{org}/{repo}.md`.
|
3. ONLY edit files under `<memory_dir>/repos/{host}/{org}/{repo}*.md` (активный файл + замороженные для dedup). НЕ создавай файлы вне этого pattern'а.
|
||||||
4. ONLY read files under `docs/handoff/` and `docs/decisions/`.
|
4. Read PR body via `gh pr view <N> --json body,title`. Optionally read `docs/decisions/*-pr-<N>-*.md` if exists (historical ADR). Do NOT read docs/handoff/ — handoff files are deprecated.
|
||||||
5. Receipt is mandatory even if no durable records found.
|
5. Receipt is mandatory even if no durable records found.
|
||||||
6. If memory file doesn't exist — create it with proper frontmatter (title, tags, summary, created, updated, importance).
|
6. If memory file doesn't exist — create it with proper frontmatter (title, tags, summary, created, updated, importance).
|
||||||
7. Для debug-вывода используй `pwd`/`ls`/`cat`/`printenv`/`echo` (все в allow-list).
|
7. Для debug-вывода используй `pwd`/`ls`/`cat`/`printenv`/`echo` (все в allow-list).
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,14 @@ permission:
|
||||||
"cat *": allow
|
"cat *": allow
|
||||||
"head*": allow
|
"head*": allow
|
||||||
"tail*": allow
|
"tail*": allow
|
||||||
|
"sed -n *": allow
|
||||||
"wc*": allow
|
"wc*": allow
|
||||||
"diff*": allow
|
"diff*": allow
|
||||||
|
"date": allow
|
||||||
|
"date *": allow
|
||||||
|
"sort": allow
|
||||||
|
"sort *": allow
|
||||||
|
"sort * -o *": deny
|
||||||
"gh pr diff*": allow
|
"gh pr diff*": allow
|
||||||
"gh pr checkout*": allow
|
"gh pr checkout*": allow
|
||||||
"gh issue*": allow
|
"gh issue*": allow
|
||||||
|
|
@ -103,10 +109,18 @@ You are a global code reviewer. Your job: review PRs against project skills and
|
||||||
|
|
||||||
## Investigation Budget
|
## Investigation Budget
|
||||||
|
|
||||||
You have a maximum of ~15 steps for investigation (Setup + checklist).
|
You have a maximum of ~15 steps for general investigation (Setup + checklist).
|
||||||
After that, you MUST call post-review — even if you haven't checked everything.
|
After that, you MUST call post-review — even if you haven't checked everything.
|
||||||
An incomplete review with verdict NEEDS_DISCUSSION is better than an infinite investigation.
|
An incomplete review with verdict NEEDS_DISCUSSION is better than an infinite
|
||||||
Do NOT repeatedly verify references in agent .md files — read once, assess, move on.
|
investigation.
|
||||||
|
|
||||||
|
Cross-file impact analysis: до 10 доп. шагов на проверку readers/writers (не
|
||||||
|
считается против основного budget 15). Это необходимо для детерминированных
|
||||||
|
связей writer↔reader.
|
||||||
|
|
||||||
|
Не повторять проверку ссылок в agent .md files БЕЗ причины — но ЕСЛИ PR
|
||||||
|
изменяет writer (агент/промпт/формат/литерал/frontmatter key/env var) —
|
||||||
|
проверка readers обязательна (см. Section 10).
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
|
|
||||||
|
|
@ -197,22 +211,49 @@ Examples of project-specific rules:
|
||||||
- No `.env` or secret files in the diff.
|
- No `.env` or secret files in the diff.
|
||||||
- Branch name is descriptive.
|
- Branch name is descriptive.
|
||||||
|
|
||||||
### 9. Documentation (if docs/project-map/ exists)
|
### 9. PR body quality
|
||||||
|
|
||||||
- Project map files accurately reflect current project structure
|
- PR body содержит `## Что сделано`, `## Почему`, `## Watch out` заполнены
|
||||||
- New modules have corresponding map files in `docs/project-map/`
|
осмысленно (не пустые плейсхолдеры, `—` допустим для Watch out/Pending если
|
||||||
- Deleted/renamed modules have updated or removed map files
|
нет контента). Если PR body неполный → REQUEST_CHANGES.
|
||||||
- No stale references to files or directories that no longer exist
|
|
||||||
- Map files follow the template (frontmatter + structure + purpose)
|
|
||||||
|
|
||||||
### 10. Handoff & ADR (quick check)
|
## 10. Cross-file impact analysis
|
||||||
|
|
||||||
- `docs/handoff/pr-<N>-<slug>.md` exists in the diff (N = PR number)
|
Для каждого изменённого файла в PR:
|
||||||
- Handoff has all sections: Что сделано, Почему, Pending, Watch out
|
- `rg` по репо — кто читает/пишет тот же ресурс (файл-путь pattern, формат,
|
||||||
- Handoff content is meaningful — not empty placeholders
|
литерал, env var, frontmatter key, comment format).
|
||||||
- If PR introduces architectural changes → `docs/decisions/<NN>-<title>.md` exists
|
- Категории связей:
|
||||||
- ADR has: Статус, Контекст, Решение, Альтернативы
|
- oracle-скрипты (`pipeline-status.py`, `spec-status.py`,
|
||||||
- If handoff/ADR missing or empty → REQUEST_CHANGES
|
`project-status.py`) — читают форматы/файлы.
|
||||||
|
- валидаторы (`create-issue.ts`, `create-readme.ts`) — парсят структуры.
|
||||||
|
- агенты (`memory-syncer.md`) — пишут файлы, которые оракулы читают.
|
||||||
|
- промпты (`skills/*/SKILL.md`) — определяют поведение, которое оракулы
|
||||||
|
проверяют.
|
||||||
|
- `opencode.json` deny-rules — определяют, какие bash-команды запрещены.
|
||||||
|
- Если writer change требует paired reader update → REQUEST_CHANGES + ТЗ
|
||||||
|
(шаблон ниже в разделе "Cross-file impact: missing paired update").
|
||||||
|
|
||||||
|
**Когда cross-file check обязателен:** PR изменяет writer (агент/промпт/формат,
|
||||||
|
литерал, frontmatter key, env var, файл-путь pattern).
|
||||||
|
**Когда cross-file check опционален:** PR cosmetic (README typo, comment) или
|
||||||
|
refactor без behavior change (signature unchanged, format preserved).
|
||||||
|
**Когда cross-file check skip:** PR добавляет новый файл без readers — 8-я
|
||||||
|
секция "нет связанных компонентов", reviewer APPROVE.
|
||||||
|
|
||||||
|
**Edge cases (verdict mapping):**
|
||||||
|
- PR cosmetic (README typo, comment) — cross-file check skip (нет writer
|
||||||
|
change) → APPROVE (если нет других critical).
|
||||||
|
- PR refactor без behavior change (signature unchanged, format preserved) —
|
||||||
|
cross-file check опционален → APPROVE.
|
||||||
|
- PR добавляет новый файл, readers не найдены (`rg` пуст) → APPROVE (нет
|
||||||
|
readers = нет breakage risk).
|
||||||
|
- PR меняет writer И reader в одном PR (paired update в PR) → APPROVE
|
||||||
|
(связь обновлена совместно, окно сломанного main закрыто).
|
||||||
|
- PR меняет writer, reader fix большой (>100 строк) → REQUEST_CHANGES + ТЗ,
|
||||||
|
author решает разбить PR (writer отдельно, writer+reader вместе).
|
||||||
|
- explore subagent нашёл false positive (candidate не связан) — reviewer
|
||||||
|
отмечает в body "отвергнуто, причина: ..." и не блокирует merge.
|
||||||
|
- `rg` не нашёл readers — reviewer APPROVE (нет readers = нет breakage risk).
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
|
|
@ -263,6 +304,48 @@ Body format (without heading — tool adds `## Code Review Summary` and `### Ver
|
||||||
Fix: suggestion
|
Fix: suggestion
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Если найден missing paired update (см. Section 10) — добавить в body блок:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Cross-file impact: missing paired update
|
||||||
|
|
||||||
|
PR меняет X (`file:line`). Y зависит от X:
|
||||||
|
- Y читает {ресурс} (`file:line`)
|
||||||
|
- X меняет {ресурс} → Y сломается
|
||||||
|
|
||||||
|
ТЗ на fix Y:
|
||||||
|
- Что: {описание fix}
|
||||||
|
- Где: {file:line}
|
||||||
|
- Контракт: {что Y должен делать после fix}
|
||||||
|
- Тесты: {что обновить}
|
||||||
|
|
||||||
|
Добавьте fix Y в этот PR. Если fix большой — разбейте PR (writer отдельно,
|
||||||
|
writer+reader вместе).
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример (кейс #238→#244):
|
||||||
|
```
|
||||||
|
## Cross-file impact: missing paired update
|
||||||
|
|
||||||
|
PR #239 меняет `memory-syncer.md:83-95` (файл-ротация: пишет в
|
||||||
|
`{repo}-002.md`).
|
||||||
|
`pipeline-status.py:check_memory` / `get_memory_file_path()` зависит от
|
||||||
|
memory-syncer:
|
||||||
|
- `pipeline-status.py:check_memory` читает `{repo}.md` для `PR#N` receipt
|
||||||
|
(`pipeline-status.py:line`)
|
||||||
|
- memory-syncer теперь пишет в `{repo}-002.md` (when `{repo}.md` ≥50 KB) →
|
||||||
|
оракул не найдёт receipt → MEMORY фаза зависает
|
||||||
|
|
||||||
|
ТЗ на fix:
|
||||||
|
- Что: `get_memory_file_path()` должен сканировать `{repo}*.md` glob, не
|
||||||
|
только `{repo}.md`
|
||||||
|
- Где: `.opencode/scripts/pipeline-status.py:get_memory_file_path()`
|
||||||
|
- Контракт: returns list of paths matching `{repo}*.md`, sorted
|
||||||
|
- Тесты: `test_pipeline_status.py` — add test for multi-file scan
|
||||||
|
|
||||||
|
Добавьте fix в этот PR. ~20 строк в pipeline-status.py + ~30 строк тестов.
|
||||||
|
```
|
||||||
|
|
||||||
Do NOT attempt merge. Stop and wait for fixes.
|
Do NOT attempt merge. Stop and wait for fixes.
|
||||||
After this call, you MUST respond with your review text only. Do NOT call any more tools.
|
After this call, you MUST respond with your review text only. Do NOT call any more tools.
|
||||||
|
|
||||||
|
|
@ -314,6 +397,36 @@ If `post-review` returns a string starting with `⚠️ ...failed` (e.g. `⚠️
|
||||||
9. Для получения login автора PR используй `gh pr view --json author` (НЕ `gh api user` — broad API call, не в allow-list, вызывает doom-loop).
|
9. Для получения login автора PR используй `gh pr view --json author` (НЕ `gh api user` — broad API call, не в allow-list, вызывает doom-loop).
|
||||||
10. Для debug-вывода используй `pwd`/`ls`/`cat` — НЕ `echo` (не в allow-list).
|
10. Для debug-вывода используй `pwd`/`ls`/`cat` — НЕ `echo` (не в allow-list).
|
||||||
|
|
||||||
|
## Cross-file impact examples
|
||||||
|
|
||||||
|
### #238→#244: memory-syncer ↔ pipeline-status
|
||||||
|
- PR #239 changed `memory-syncer.md` (file-rotation: writes to `{repo}-002.md`)
|
||||||
|
- `pipeline-status.py:check_memory` read only `{repo}.md` → broke (MEMORY
|
||||||
|
phase hung)
|
||||||
|
- Reviewer APPROVE → 69 min later #245 fix-up merged
|
||||||
|
- Prevention: reviewer должен был `rg "memory" .opencode/scripts/` → найти
|
||||||
|
`pipeline-status.py:check_memory` → REQUEST_CHANGES + ТЗ
|
||||||
|
|
||||||
|
## Known deterministic links
|
||||||
|
|
||||||
|
Reference list writer↔reader в этом репо. Обновляется при появлении новых
|
||||||
|
детерминированных связей.
|
||||||
|
|
||||||
|
- `memory-syncer.md` (writer of `{repo}*.md`) ↔ `pipeline-status.py:check_memory`
|
||||||
|
/ `get_memory_files` (reader) — MEMORY phase expects `PR#N` literal in
|
||||||
|
memory files.
|
||||||
|
- `pipeline-status.py` (parses `### Verdict:` line) ↔ `post-review.ts` (writes
|
||||||
|
`### Verdict: <verdict>` heading in PR comments).
|
||||||
|
- `pipeline-status.py` (MEMORY phase expects `PR#N` literal) ↔
|
||||||
|
`memory-syncer.md` (must write `PR#N` without space — otherwise receipt
|
||||||
|
not found).
|
||||||
|
- `spec-status.py` (reader of `docs/spec/*.md`) ↔ `spec` skill /
|
||||||
|
`project-template` skill (writers of spec phases).
|
||||||
|
- `project-status.py:check_readme` (oracle, validator) ↔ `create-readme.ts`
|
||||||
|
(writer of README with delimiter tags) ↔ `repo-readme` skill.
|
||||||
|
- `opencode.json` deny-rules ↔ `*-status.ts` native tools (must exist as
|
||||||
|
alternatives — see ADR-019; deny on direct `python3 .../pipeline-status.py`).
|
||||||
|
|
||||||
## Bug Discovery
|
## Bug Discovery
|
||||||
|
|
||||||
If you find a bug outside the current PR/task scope — you MUST load skill `bug-discovery` via `skill("bug-discovery")` tool and follow its protocol. Do NOT fix the bug yourself. Report to orchestrator: "Created issue #N: ...".
|
If you find a bug outside the current PR/task scope — you MUST load skill `bug-discovery` via `skill("bug-discovery")` tool and follow its protocol. Do NOT fix the bug yourself. Report to orchestrator: "Created issue #N: ...".
|
||||||
5
.opencode/commands/audit.md
Normal file
5
.opencode/commands/audit.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
description: Audit existing project — verdict + auto-create issues for refactor
|
||||||
|
agent: build
|
||||||
|
---
|
||||||
|
Load the `audit` skill via `skill({name: "audit"})` and follow its ПРОТОКОЛ strictly. Главный агент — оркестратор: `project-status` tool (read-only) + `explore` subagent (code-standards) + вопрос юзеру + `task(general)` для create-issue. Не делает edit/read/`gh issue create` сам.
|
||||||
61
.opencode/commands/cover.md
Normal file
61
.opencode/commands/cover.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
description: Generate a branded 1024×1024 cover (Lime style) via draw-image
|
||||||
|
agent: build
|
||||||
|
---
|
||||||
|
Generate a repository cover image following the **hardcoded slaid098 Lime standard**.
|
||||||
|
Style is NEVER asked of the user and NEVER overridden — only content questions are asked.
|
||||||
|
|
||||||
|
## Hardcoded standard (НЕ спрашивается, НЕ переопределяется)
|
||||||
|
|
||||||
|
- `template` = `cover` (1024×1024, единственный доступный шаблон в `.opencode/draw-image/templates/cover.svg`).
|
||||||
|
- Palette = **Lime** from `.opencode/draw-image/brand.json`: base `#0a0a0a`, surface `#121212`, accent `#ccff00` (icons), fg `#ededed` (text), muted `#a1a1aa`.
|
||||||
|
- `title`: ровно ОДИН заголовок, **lowercase English**, без исключений. Авто-деривация из имени репо (см. правила ниже).
|
||||||
|
- `subtitle`: **ОТСУТСТВУЕТ**. Флаг `--subtitle` / параметр `subtitle` запрещён стандартом. Передача subtitle = ошибка команды.
|
||||||
|
- `out` = `assets/cover.png` (относительно корня репо). Глобальный путь для витрины slaid098.dev — без исключений.
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
- `icon` — **обязательный**. Lucide-иконка из `.opencode/draw-image/icons/lucide/` (2007 шт.) ИЛИ brand-logo из `.opencode/draw-image/brand-logos/`. Передаётся без расширения (напр. `mic`, не `mic.svg`).
|
||||||
|
- `sub-icon` — **опциональный**. Brand-logo из `.opencode/draw-image/brand-logos/` для показа brand-принадлежности.
|
||||||
|
- `badge` — **опциональный**. Путь к SVG-бейджу.
|
||||||
|
|
||||||
|
## Title derivation rules
|
||||||
|
|
||||||
|
- Имя репо → lowercase, дефисы/подчёркивания → пробелы: `opencode-voice-dictation` → `opencode voice dictation`.
|
||||||
|
- camelCase / UPPER → lowercase: `MyRepo` → `myrepo`.
|
||||||
|
- Числа сохраняются: `video-uniq-2` → `video uniq 2`.
|
||||||
|
- Если имя длиннее ~40 символов → спроси у пользователя сокращение.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. **Read repo** — прочитай `README.md`, манифест (`package.json` / `pyproject.toml` / иной), пробегись по `src/` (топ-уровень). Определи purpose репо.
|
||||||
|
2. **Infer** — выведи: `title` (по правилам выше), candidate `icon` (по purpose, напр. voice-dictation → `mic`), candidate `sub-icon` (по brand-принадлежности из README/манифеста).
|
||||||
|
3. **Ask user** — задай 2-3 вопроса ТОЛЬКО по контенту, который не удалось вывести: подтвердить/скорректировать `icon`, `sub-icon`, `title`. НЕ задавай вопросов по стилю — стиль захардкожен.
|
||||||
|
4. **Pre-check** — если `assets/cover.png` уже существует, спроси «перезаписать?» перед рендером.
|
||||||
|
5. **Ensure `assets/` dir** — если директории `assets/` нет, создай её автоматически.
|
||||||
|
6. **Render** — вызови tool `draw-image`:
|
||||||
|
```
|
||||||
|
draw-image({ template: "cover", title: "<lowercase english>", slots: "icon=<value>,sub-icon=<value>", out: "assets/cover.png" })
|
||||||
|
```
|
||||||
|
НЕ передавай `subtitle`. Если tool `draw-image` недоступен — остановись с ошибкой: «draw-image tool недоступен — невозможно сгенерировать кавер».
|
||||||
|
7. **Validate README** — проверь, что в `README.md` есть ``. Если нет — добавь ссылку сразу после заголовка H1 (прямая проверка; опционально — `project-status.py check_readme`). Если `check_readme` падает (README без delimiter tags) — предупреди, но кавер всё равно остаётся отрендеренным (кавер ≠ README).
|
||||||
|
8. **Done** — сообщи путь к готовому PNG.
|
||||||
|
|
||||||
|
## Extension mechanism (НЕ хардкод брендов)
|
||||||
|
|
||||||
|
Команда НЕ хардкодит конкретные бренды. **OpenCode упоминается ниже только как ПРИМЕР** — это не special-case в коде. Для любого бренда: если в `.opencode/draw-image/brand-logos/` есть SVG, его можно использовать как `sub-icon`. Расширение = добавление brand-logo SVG в `draw-image/brand-logos/`, а НЕ изменение этой команды.
|
||||||
|
|
||||||
|
> Пример: README указывает на принадлежность к OpenCode-ecosystem → предложи `sub-icon=opencode` (файл `brand-logos/opencode.svg`). Для не-OpenCode репо `sub-icon` пропускается.
|
||||||
|
|
||||||
|
## Граничные случаи
|
||||||
|
|
||||||
|
- **Нет README / нет манифеста** → purpose не выводится → спроси пользователя `icon` + `title` напрямую (без infer).
|
||||||
|
- **Имя репо camelCase / UPPER** → нормализуется в lowercase (`MyRepo` → `myrepo`).
|
||||||
|
- **Имя репо с числами** → сохраняются (`video-uniq-2` → `video uniq 2`).
|
||||||
|
- **Имя репо > ~40 символов** → спроси сокращение.
|
||||||
|
- **Пользователь хочет не-Lime кавер** → отказ: «команда /cover поддерживает только Lime-стиль. Кастомизация вне scope.»
|
||||||
|
- **Существующий `assets/cover.png`** → спроси «перезаписать?» перед рендером.
|
||||||
|
- **Lucide-иконка не найдена** → ошибка со списком похожих иконок (fuzzy match по имени в `icons/lucide/`).
|
||||||
|
- **`check_readme` падает** (README без delimiter tags) → предупреди, но кавер рендерится.
|
||||||
|
- **`draw-image` tool недоступен** → ясная ошибка, рендер невозможен.
|
||||||
|
- **`assets/` директория отсутствует** → создаётся автоматически.
|
||||||
5
.opencode/commands/project-template.md
Normal file
5
.opencode/commands/project-template.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
description: Project template — init new project (cookiecutter + GitHub remote) or check existing (project-status)
|
||||||
|
agent: build
|
||||||
|
---
|
||||||
|
Load the `project-template` skill via `skill({name: "project-template"})` and follow its ПРОТОКОЛ strictly. Два flow: init (cookiecutter по типу + git + gh repo create + branch protection + project-status) или check (project-status → отчёт → рекомендации через subagents). Главный агент — оркестратор: вопросы юзеру + `project-status` tool (read-only) + делегирование. Не делает cookiecutter/git/gh напрямую.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
description: Standardize repo README + cover image (create/validate)
|
description: Standardize repo README + cover image (create)
|
||||||
agent: build
|
agent: build
|
||||||
---
|
---
|
||||||
Load the `repo-readme` skill via `skill({name: "repo-readme"})` and follow its ПРОТОКОЛ strictly. Workflow: create-readme (generate) → draw-image (cover) → validate → fix cycle. One command = full README + cover standardization.
|
Load the `repo-readme` skill via `skill({name: "repo-readme"})` and follow its ПРОТОКОЛ strictly. Workflow: create-readme (create) → draw-image (cover) → проверка через project-status check_readme. One command = full README + cover standardization.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
description: Run pipeline — autonomous 7-phase PR pipeline
|
description: Run pipeline — autonomous 6-phase PR pipeline
|
||||||
agent: build
|
agent: build
|
||||||
---
|
---
|
||||||
Load the `run-pipeline` skill via `skill({name: "run-pipeline"})` and follow its ПРОТОКОЛ strictly. Each iteration: call `pipeline-status` tool, execute the `NEXT:` action it returns, repeat until COMPLETE or STOP. Полностью автономно — 1 строка прогресса после каждой фазы, STOP на AMBIGUOUS/error.
|
Load the `run-pipeline` skill via `skill({name: "run-pipeline"})` and follow its ПРОТОКОЛ strictly. Each iteration: call `pipeline-status` tool, execute the `NEXT:` action it returns, repeat until COMPLETE or STOP. Полностью автономно — 1 строка прогресса после каждой фазы, STOP на AMBIGUOUS/error.
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"
|
import { writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
import os from "node:os"
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
import { loadBrand } from "./src/config.ts"
|
import { loadBrand } from "./src/config.ts"
|
||||||
import { loadTemplate, buildSvg, computeHash } from "./src/render.ts"
|
import { loadTemplate, buildSvg, computeHash } from "./src/render.ts"
|
||||||
|
|
@ -24,7 +25,7 @@ function parseArgs(argv: string[]): Record<string, string> {
|
||||||
function main() {
|
function main() {
|
||||||
const argv = process.argv.slice(2)
|
const argv = process.argv.slice(2)
|
||||||
if (argv.length < 2 || argv[0] !== "render") {
|
if (argv.length < 2 || argv[0] !== "render") {
|
||||||
process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--out path]\n')
|
process.stderr.write('usage: node cli.ts render <template> --title "..." [--slots icon=mic] [--subtitle text] [--out path] [--template-dir dir]\n')
|
||||||
process.exit(2)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ function main() {
|
||||||
const title = opts.title ?? ""
|
const title = opts.title ?? ""
|
||||||
const subtitle = opts.subtitle
|
const subtitle = opts.subtitle
|
||||||
const out = opts.out ?? "./assets/cover.png"
|
const out = opts.out ?? "./assets/cover.png"
|
||||||
|
const templateDir = opts["template-dir"]
|
||||||
|
|
||||||
const slots: Record<string, string> = {}
|
const slots: Record<string, string> = {}
|
||||||
if (opts.slots) {
|
if (opts.slots) {
|
||||||
|
|
@ -43,7 +45,7 @@ function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const brand = loadBrand(__dirname)
|
const brand = loadBrand(__dirname)
|
||||||
const templateSvg = loadTemplate(__dirname, template)
|
const templateSvg = loadTemplate(__dirname, template, templateDir)
|
||||||
const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out })
|
const hash = computeHash(brand, templateSvg, { template, title, subtitle, slots, out })
|
||||||
|
|
||||||
const outResolved = path.resolve(out)
|
const outResolved = path.resolve(out)
|
||||||
|
|
@ -61,7 +63,9 @@ function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const svg = buildSvg(templateSvg, brand, { template, title, subtitle, slots, out }, __dirname)
|
const svg = buildSvg(templateSvg, brand, { template, title, subtitle, slots, out }, __dirname)
|
||||||
const tmpSvg = path.join(__dirname, ".tmp-render.svg")
|
const tmpSvg = path.join(os.tmpdir(), `draw-image-${process.pid}.svg`)
|
||||||
|
|
||||||
|
try {
|
||||||
writeFileSync(tmpSvg, svg)
|
writeFileSync(tmpSvg, svg)
|
||||||
|
|
||||||
const r = spawnSync("node", [path.join(__dirname, "render.mjs"), tmpSvg, outResolved], {
|
const r = spawnSync("node", [path.join(__dirname, "render.mjs"), tmpSvg, outResolved], {
|
||||||
|
|
@ -79,6 +83,9 @@ function main() {
|
||||||
}
|
}
|
||||||
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n")
|
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\n")
|
||||||
console.log(JSON.stringify({ path: outResolved, hash, status: "rendered" }))
|
console.log(JSON.stringify({ path: outResolved, hash, status: "rendered" }))
|
||||||
|
} finally {
|
||||||
|
if (existsSync(tmpSvg)) rmSync(tmpSvg, { force: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
@ -70,8 +70,10 @@ export function computeHash(brand: Brand, templateSvg: string, args: RenderArgs)
|
||||||
return createHash("sha256").update(data).digest("hex")
|
return createHash("sha256").update(data).digest("hex")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadTemplate(drawImageDir: string, name: string): string {
|
export function loadTemplate(drawImageDir: string, name: string, templateDir?: string): string {
|
||||||
const templatePath = path.join(drawImageDir, "templates", `${name}.svg`)
|
const dir = templateDir ?? "templates"
|
||||||
|
const base = path.isAbsolute(dir) ? dir : path.join(drawImageDir, dir)
|
||||||
|
const templatePath = path.join(base, `${name}.svg`)
|
||||||
return readFileSync(templatePath, "utf-8")
|
return readFileSync(templatePath, "utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
|
<!-- slot: name=icon, x=252, y=180, w=520, h=520, fit=contain, recolor=accent -->
|
||||||
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
|
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
|
||||||
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
|
|
||||||
<rect width="1024" height="1024" fill="{{base}}" />
|
<rect width="1024" height="1024" fill="{{base}}" />
|
||||||
<text x="512" y="870" font-family="Geist Sans, sans-serif" font-size="72" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
|
<text x="512" y="830" font-family="Geist Sans, sans-serif" font-size="80" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
|
||||||
<text x="512" y="930" font-family="Geist Sans, sans-serif" font-size="36" font-weight="400" fill="{{muted}}" text-anchor="middle">{{subtitle}}</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 744 B After Width: | Height: | Size: 470 B |
59
.opencode/draw-image/tests/cleanup.test.ts
Normal file
59
.opencode/draw-image/tests/cleanup.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||||
|
import { existsSync, readdirSync, rmSync, mkdirSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { spawnSync } from "node:child_process"
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
|
const TMP = path.join(os.tmpdir(), `draw-image-cleanup-${process.pid}`)
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
mkdirSync(TMP, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
||||||
|
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
cwd: DRAW_IMAGE_DIR,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cleanup — temp SVG after render", () => {
|
||||||
|
test("no .tmp-render.svg left in draw-image dir after CLI render", () => {
|
||||||
|
const out = path.join(TMP, "cleanup.png")
|
||||||
|
const leftoverPath = path.join(DRAW_IMAGE_DIR, ".tmp-render.svg")
|
||||||
|
if (existsSync(leftoverPath)) rmSync(leftoverPath, { force: true })
|
||||||
|
|
||||||
|
const r = runCli(["render", "cover", "--title", "Cleanup", "--out", out, "--template-dir", "tests/fixtures"])
|
||||||
|
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||||
|
|
||||||
|
expect(existsSync(leftoverPath), ".tmp-render.svg must not remain in package dir").toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unique temp SVG uses process pid under os.tmpdir and is removed", () => {
|
||||||
|
const expectedTmp = path.join(os.tmpdir(), `draw-image-${process.pid}.svg`)
|
||||||
|
if (existsSync(expectedTmp)) rmSync(expectedTmp, { force: true })
|
||||||
|
|
||||||
|
const out = path.join(TMP, "pid.png")
|
||||||
|
const r = runCli(["render", "cover", "--title", "Pid Check", "--out", out, "--template-dir", "tests/fixtures"])
|
||||||
|
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||||
|
|
||||||
|
expect(existsSync(expectedTmp), `temp svg at ${expectedTmp} must be cleaned up`).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("package dir contains no stray .svg files after render", () => {
|
||||||
|
const out = path.join(TMP, "no-stray.png")
|
||||||
|
const r = runCli(["render", "cover", "--title", "No Stray", "--out", out, "--template-dir", "tests/fixtures"])
|
||||||
|
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||||
|
|
||||||
|
const stray = readdirSync(DRAW_IMAGE_DIR).filter((f) => f.startsWith(".tmp") && f.endsWith(".svg"))
|
||||||
|
expect(stray, `stray temp svg files: ${stray.join(", ")}`).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
import { describe, test, expect, beforeAll } from "vitest"
|
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||||
import { existsSync, rmSync, mkdirSync, writeFileSync, copyFileSync, readFileSync } from "node:fs"
|
import { existsSync, rmSync, mkdirSync, writeFileSync, copyFileSync, readFileSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
import { validateBrand } from "../src/config"
|
import { validateBrand } from "../src/config"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
const TMP = "/tmp/draw-image-bad-input"
|
const TMP = path.join(os.tmpdir(), `draw-image-bad-input-${process.pid}`)
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
mkdirSync(TMP, { recursive: true })
|
mkdirSync(TMP, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
describe("e2e — bad input (unit-level, no file mutation)", () => {
|
describe("e2e — bad input (unit-level, no file mutation)", () => {
|
||||||
test("invalid brand.json rejected by validateBrand", () => {
|
test("invalid brand.json rejected by validateBrand", () => {
|
||||||
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa" }))
|
expect(() => validateBrand({ base: "#0a0a0a", surface: "#121212", fg: "#ededed", muted: "#a1a1aa" }))
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
import { describe, test, expect, beforeAll } from "vitest"
|
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
const TMP = "/tmp/draw-image-e2e-no-icon"
|
const TMP = path.join(os.tmpdir(), `draw-image-e2e-no-icon-${process.pid}`)
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
mkdirSync(TMP, { recursive: true })
|
mkdirSync(TMP, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
||||||
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
|
|
@ -23,7 +28,7 @@ function runCli(args: string[]): { status: number; stdout: string; stderr: strin
|
||||||
describe("e2e — no icon render", () => {
|
describe("e2e — no icon render", () => {
|
||||||
test("exit 0 and valid PNG without icon slot", () => {
|
test("exit 0 and valid PNG without icon slot", () => {
|
||||||
const out = path.join(TMP, "no-icon.png")
|
const out = path.join(TMP, "no-icon.png")
|
||||||
const r = runCli(["render", "cover", "--title", "Default Brand", "--out", out])
|
const r = runCli(["render", "cover", "--title", "Default Brand", "--out", out, "--template-dir", "tests/fixtures"])
|
||||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||||
expect(existsSync(out)).toBe(true)
|
expect(existsSync(out)).toBe(true)
|
||||||
const buf = readFileSync(out)
|
const buf = readFileSync(out)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
import { describe, test, expect, beforeAll } from "vitest"
|
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
|
import { loadBrand } from "../src/config"
|
||||||
|
import { buildSvg } from "../src/render"
|
||||||
|
import { loadFixture } from "./helpers/fixtures"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
const TMP = "/tmp/draw-image-optional-e2e"
|
const TMP = path.join(os.tmpdir(), `draw-image-optional-e2e-${process.pid}`)
|
||||||
const TMP_SVG = path.join(DRAW_IMAGE_DIR, ".tmp-render.svg")
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
mkdirSync(TMP, { recursive: true })
|
mkdirSync(TMP, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
||||||
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
|
|
@ -34,11 +41,13 @@ function assertPng(filePath: string) {
|
||||||
describe("e2e — optional subtitle and badge via CLI", () => {
|
describe("e2e — optional subtitle and badge via CLI", () => {
|
||||||
test("render with title only → exit 0, valid PNG, svg has no empty badge and no subtitle", () => {
|
test("render with title only → exit 0, valid PNG, svg has no empty badge and no subtitle", () => {
|
||||||
const out = path.join(TMP, "optional.png")
|
const out = path.join(TMP, "optional.png")
|
||||||
const r = runCli(["render", "cover", "--title", "Test", "--out", out])
|
const r = runCli(["render", "cover", "--title", "Test", "--out", out, "--template-dir", "tests/fixtures"])
|
||||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||||
assertPng(out)
|
assertPng(out)
|
||||||
|
|
||||||
const svg = readFileSync(TMP_SVG, "utf-8")
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
|
const templateSvg = loadFixture("cover")
|
||||||
|
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "Test", out }, DRAW_IMAGE_DIR)
|
||||||
expect(svg).not.toContain('x="780" y="780"')
|
expect(svg).not.toContain('x="780" y="780"')
|
||||||
expect(svg).not.toContain('y="930"')
|
expect(svg).not.toContain('y="930"')
|
||||||
expect(svg).not.toContain("{{subtitle}}")
|
expect(svg).not.toContain("{{subtitle}}")
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
const TMP = "/tmp/draw-image-e2e"
|
const TMP = path.join(os.tmpdir(), `draw-image-e2e-${process.pid}`)
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
mkdirSync(TMP, { recursive: true })
|
mkdirSync(TMP, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
function runCli(args: string[]): { status: number; stdout: string; stderr: string } {
|
||||||
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
return spawnSync("node", ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), ...args], {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
|
|
@ -37,7 +42,7 @@ function assertPng1024(filePath: string) {
|
||||||
describe("e2e — full CLI render", () => {
|
describe("e2e — full CLI render", () => {
|
||||||
test("render cover with title and icon", () => {
|
test("render cover with title and icon", () => {
|
||||||
const out = path.join(TMP, "e2e.png")
|
const out = path.join(TMP, "e2e.png")
|
||||||
const r = runCli(["render", "cover", "--title", "E2E", "--slots", "icon=mic", "--out", out])
|
const r = runCli(["render", "cover", "--title", "E2E", "--slots", "icon=mic", "--out", out, "--template-dir", "tests/fixtures"])
|
||||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||||
const result = JSON.parse(r.stdout)
|
const result = JSON.parse(r.stdout)
|
||||||
expect(result.status).toBe("rendered")
|
expect(result.status).toBe("rendered")
|
||||||
|
|
@ -50,14 +55,14 @@ describe("e2e — full CLI render", () => {
|
||||||
|
|
||||||
test("render without icon succeeds", () => {
|
test("render without icon succeeds", () => {
|
||||||
const out = path.join(TMP, "e2e-no-icon.png")
|
const out = path.join(TMP, "e2e-no-icon.png")
|
||||||
const r = runCli(["render", "cover", "--title", "No Icon", "--out", out])
|
const r = runCli(["render", "cover", "--title", "No Icon", "--out", out, "--template-dir", "tests/fixtures"])
|
||||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||||
assertPng1024(out)
|
assertPng1024(out)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("render with subtitle", () => {
|
test("render with subtitle", () => {
|
||||||
const out = path.join(TMP, "e2e-sub.png")
|
const out = path.join(TMP, "e2e-sub.png")
|
||||||
const r = runCli(["render", "cover", "--title", "Main", "--subtitle", "Sub", "--out", out])
|
const r = runCli(["render", "cover", "--title", "Main", "--subtitle", "Sub", "--out", out, "--template-dir", "tests/fixtures"])
|
||||||
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
expect(r.status, `stderr: ${r.stderr}`).toBe(0)
|
||||||
assertPng1024(out)
|
assertPng1024(out)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
8
.opencode/draw-image/tests/fixtures/cover.svg
vendored
Normal file
8
.opencode/draw-image/tests/fixtures/cover.svg
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<!-- slot: name=icon, x=312, y=180, w=400, h=400, fit=contain, recolor=accent -->
|
||||||
|
<!-- slot: name=sub-icon, x=412, y=600, w=200, h=200, fit=contain, recolor=accent -->
|
||||||
|
<!-- slot: name=badge, x=780, y=780, w=180, h=180, fit=contain, recolor=none, bg=surface, border=accent, radius=0.5 -->
|
||||||
|
<rect width="1024" height="1024" fill="{{base}}" />
|
||||||
|
<text x="512" y="870" font-family="Geist Sans, sans-serif" font-size="72" font-weight="700" fill="{{fg}}" text-anchor="middle">{{title}}</text>
|
||||||
|
<text x="512" y="930" font-family="Geist Sans, sans-serif" font-size="36" font-weight="400" fill="{{muted}}" text-anchor="middle">{{subtitle}}</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 744 B |
13
.opencode/draw-image/tests/helpers/fixtures.ts
Normal file
13
.opencode/draw-image/tests/helpers/fixtures.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const FIXTURES_DIR = path.resolve(__dirname, "..", "fixtures")
|
||||||
|
|
||||||
|
export const FIXTURES_DIR_ABS = FIXTURES_DIR
|
||||||
|
|
||||||
|
export function loadFixture(name: string): string {
|
||||||
|
const fixturePath = path.join(FIXTURES_DIR, `${name}.svg`)
|
||||||
|
return readFileSync(fixturePath, "utf-8")
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { describe, test, expect, beforeAll } from "vitest"
|
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||||
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"
|
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
import { loadBrand } from "../src/config"
|
|
||||||
import { loadTemplate, buildSvg, computeHash } from "../src/render"
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
const TMP = "/tmp/draw-image-idempotency"
|
const TMP = path.join(os.tmpdir(), `draw-image-idempotency-${process.pid}`)
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
mkdirSync(TMP, { recursive: true })
|
mkdirSync(TMP, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
function renderCli(template: string, title: string, out: string, slots?: string): { status: number; stdout: string; stderr: string } {
|
function renderCli(template: string, title: string, out: string, slots?: string): { status: number; stdout: string; stderr: string } {
|
||||||
const args = ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", template, "--title", title, "--out", out]
|
const args = ["--experimental-strip-types", path.join(DRAW_IMAGE_DIR, "cli.ts"), "render", template, "--title", title, "--out", out, "--template-dir", "tests/fixtures"]
|
||||||
if (slots) args.push("--slots", slots)
|
if (slots) args.push("--slots", slots)
|
||||||
return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR })
|
return spawnSync("node", args, { encoding: "utf-8", cwd: DRAW_IMAGE_DIR })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,44 @@
|
||||||
import { describe, test, expect, beforeAll } from "vitest"
|
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"
|
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, mkdtempSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
import { loadBrand } from "../src/config"
|
import { loadBrand } from "../src/config"
|
||||||
import { loadTemplate, buildSvg, computeHash } from "../src/render"
|
import { buildSvg, computeHash } from "../src/render"
|
||||||
|
import { loadFixture } from "./helpers/fixtures"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
const TMP = "/tmp/draw-image-integration"
|
const TMP = path.join(os.tmpdir(), `draw-image-integration-${process.pid}`)
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
mkdirSync(TMP, { recursive: true })
|
mkdirSync(TMP, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
function renderToPng(svg: string, outPath: string): void {
|
function renderToPng(svg: string, outPath: string): void {
|
||||||
const tmpSvg = path.join(TMP, "input.svg")
|
const tmpDir = mkdtempSync(path.join(os.tmpdir(), `draw-image-test-${process.pid}-`))
|
||||||
require("node:fs").writeFileSync(tmpSvg, svg)
|
const tmpSvg = path.join(tmpDir, "input.svg")
|
||||||
|
try {
|
||||||
|
writeFileSync(tmpSvg, svg)
|
||||||
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
|
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
})
|
})
|
||||||
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
|
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
|
||||||
|
} finally {
|
||||||
|
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("integration — render pipeline", () => {
|
describe("integration — render pipeline", () => {
|
||||||
test("renders valid PNG 1024x1024", () => {
|
test("renders valid PNG 1024x1024", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const args = { template: "cover", title: "Test", out: path.join(TMP, "cover.png") }
|
const args = { template: "cover", title: "Test", out: path.join(TMP, "cover.png") }
|
||||||
const hash = computeHash(brand, templateSvg, args)
|
const hash = computeHash(brand, templateSvg, args)
|
||||||
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
||||||
|
|
@ -51,7 +62,7 @@ describe("integration — render pipeline", () => {
|
||||||
|
|
||||||
test("creates output directory recursively", () => {
|
test("creates output directory recursively", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const args = { template: "cover", title: "Deep", out: path.join(TMP, "a", "b", "c", "cover.png") }
|
const args = { template: "cover", title: "Deep", out: path.join(TMP, "a", "b", "c", "cover.png") }
|
||||||
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
||||||
const outPath = path.join(TMP, "a", "b", "c", "cover.png")
|
const outPath = path.join(TMP, "a", "b", "c", "cover.png")
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,38 @@
|
||||||
import { describe, test, expect, beforeAll } from "vitest"
|
import { describe, test, expect, beforeAll, afterAll } from "vitest"
|
||||||
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"
|
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, mkdtempSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { spawnSync } from "node:child_process"
|
import { spawnSync } from "node:child_process"
|
||||||
import { loadBrand } from "../src/config"
|
import { loadBrand } from "../src/config"
|
||||||
import { loadTemplate, buildSvg } from "../src/render"
|
import { buildSvg } from "../src/render"
|
||||||
|
import { loadFixture } from "./helpers/fixtures"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
const TMP = "/tmp/draw-image-optional-integration"
|
const TMP = path.join(os.tmpdir(), `draw-image-optional-integration-${process.pid}`)
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
mkdirSync(TMP, { recursive: true })
|
mkdirSync(TMP, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
function renderToPng(svg: string, outPath: string): void {
|
function renderToPng(svg: string, outPath: string): void {
|
||||||
const tmpSvg = path.join(TMP, "input.svg")
|
const tmpDir = mkdtempSync(path.join(os.tmpdir(), `draw-image-test-${process.pid}-`))
|
||||||
|
const tmpSvg = path.join(tmpDir, "input.svg")
|
||||||
|
try {
|
||||||
writeFileSync(tmpSvg, svg)
|
writeFileSync(tmpSvg, svg)
|
||||||
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
|
const r = spawnSync("node", [path.join(DRAW_IMAGE_DIR, "render.mjs"), tmpSvg, outPath], {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
})
|
})
|
||||||
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
|
if (r.status !== 0) throw new Error(`render.mjs failed: ${r.stderr || r.stdout}`)
|
||||||
|
} finally {
|
||||||
|
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertPng(filePath: string) {
|
function assertPng(filePath: string) {
|
||||||
|
|
@ -40,7 +51,7 @@ function assertPng(filePath: string) {
|
||||||
describe("integration — optional subtitle and badge", () => {
|
describe("integration — optional subtitle and badge", () => {
|
||||||
test("render without subtitle and without badge → valid PNG", () => {
|
test("render without subtitle and without badge → valid PNG", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const args = { template: "cover", title: "Bare" }
|
const args = { template: "cover", title: "Bare" }
|
||||||
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
const svg = buildSvg(templateSvg, brand, args, DRAW_IMAGE_DIR)
|
||||||
const outPath = path.join(TMP, "bare.png")
|
const outPath = path.join(TMP, "bare.png")
|
||||||
|
|
@ -50,7 +61,7 @@ describe("integration — optional subtitle and badge", () => {
|
||||||
|
|
||||||
test("render with subtitle and badge → valid PNG", () => {
|
test("render with subtitle and badge → valid PNG", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const args = {
|
const args = {
|
||||||
template: "cover",
|
template: "cover",
|
||||||
title: "Full",
|
title: "Full",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { describe, test, expect } from "vitest"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { loadBrand } from "../src/config"
|
import { loadBrand } from "../src/config"
|
||||||
import { loadTemplate, buildSvg } from "../src/render"
|
import { buildSvg } from "../src/render"
|
||||||
|
import { loadFixture } from "./helpers/fixtures"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
|
|
@ -13,7 +14,7 @@ const SUBTITLE_Y = 'y="930"'
|
||||||
describe("unit — optional badge background", () => {
|
describe("unit — optional badge background", () => {
|
||||||
test("empty badge slot renders no bg/border rect", () => {
|
test("empty badge slot renders no bg/border rect", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Badge" }, DRAW_IMAGE_DIR)
|
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Badge" }, DRAW_IMAGE_DIR)
|
||||||
expect(svg).not.toContain(BADGE_RECT)
|
expect(svg).not.toContain(BADGE_RECT)
|
||||||
expect(svg).not.toContain('x="780" y="780"')
|
expect(svg).not.toContain('x="780" y="780"')
|
||||||
|
|
@ -21,7 +22,7 @@ describe("unit — optional badge background", () => {
|
||||||
|
|
||||||
test("filled badge slot renders bg/border rect", () => {
|
test("filled badge slot renders bg/border rect", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const svg = buildSvg(templateSvg, brand, {
|
const svg = buildSvg(templateSvg, brand, {
|
||||||
template: "cover",
|
template: "cover",
|
||||||
title: "With Badge",
|
title: "With Badge",
|
||||||
|
|
@ -36,7 +37,7 @@ describe("unit — optional badge background", () => {
|
||||||
describe("unit — optional subtitle", () => {
|
describe("unit — optional subtitle", () => {
|
||||||
test("empty subtitle removes the subtitle text line", () => {
|
test("empty subtitle removes the subtitle text line", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Sub" }, DRAW_IMAGE_DIR)
|
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Sub" }, DRAW_IMAGE_DIR)
|
||||||
expect(svg).not.toContain(SUBTITLE_Y)
|
expect(svg).not.toContain(SUBTITLE_Y)
|
||||||
expect(svg).not.toContain("{{subtitle}}")
|
expect(svg).not.toContain("{{subtitle}}")
|
||||||
|
|
@ -44,7 +45,7 @@ describe("unit — optional subtitle", () => {
|
||||||
|
|
||||||
test("set subtitle keeps the subtitle text line", () => {
|
test("set subtitle keeps the subtitle text line", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const svg = buildSvg(templateSvg, brand, {
|
const svg = buildSvg(templateSvg, brand, {
|
||||||
template: "cover",
|
template: "cover",
|
||||||
title: "Main",
|
title: "Main",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import { existsSync, rmSync, mkdirSync } from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { loadBrand } from "../src/config"
|
import { loadBrand } from "../src/config"
|
||||||
import { loadTemplate, buildSvg } from "../src/render"
|
import { buildSvg } from "../src/render"
|
||||||
import { parseSlots } from "../src/slot-parser"
|
import { parseSlots } from "../src/slot-parser"
|
||||||
import { resolveSlotContent } from "../src/resolve"
|
import { resolveSlotContent } from "../src/resolve"
|
||||||
|
import { loadFixture } from "./helpers/fixtures"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
const DRAW_IMAGE_DIR = path.resolve(__dirname, "..")
|
||||||
|
|
@ -19,7 +20,7 @@ beforeAll(() => {
|
||||||
describe("integration — slot resolution", () => {
|
describe("integration — slot resolution", () => {
|
||||||
test("icon=mic resolves lucide icon and recolors to accent", () => {
|
test("icon=mic resolves lucide icon and recolors to accent", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const slots = parseSlots(templateSvg)
|
const slots = parseSlots(templateSvg)
|
||||||
const iconSlot = slots.find((s) => s.name === "icon")!
|
const iconSlot = slots.find((s) => s.name === "icon")!
|
||||||
expect(iconSlot).toBeDefined()
|
expect(iconSlot).toBeDefined()
|
||||||
|
|
@ -34,7 +35,7 @@ describe("integration — slot resolution", () => {
|
||||||
|
|
||||||
test("icon=play resolves lucide icon", () => {
|
test("icon=play resolves lucide icon", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const slots = parseSlots(templateSvg)
|
const slots = parseSlots(templateSvg)
|
||||||
const iconSlot = slots.find((s) => s.name === "icon")!
|
const iconSlot = slots.find((s) => s.name === "icon")!
|
||||||
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "play")
|
const resolved = resolveSlotContent(iconSlot, brand, DRAW_IMAGE_DIR, "play")
|
||||||
|
|
@ -43,7 +44,7 @@ describe("integration — slot resolution", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("badge slot has bg=surface and border=accent", () => {
|
test("badge slot has bg=surface and border=accent", () => {
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const slots = parseSlots(templateSvg)
|
const slots = parseSlots(templateSvg)
|
||||||
const badgeSlot = slots.find((s) => s.name === "badge")!
|
const badgeSlot = slots.find((s) => s.name === "badge")!
|
||||||
expect(badgeSlot.bg).toBe("surface")
|
expect(badgeSlot.bg).toBe("surface")
|
||||||
|
|
@ -53,7 +54,7 @@ describe("integration — slot resolution", () => {
|
||||||
|
|
||||||
test("buildSvg inserts slot content into final SVG", () => {
|
test("buildSvg inserts slot content into final SVG", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const svg = buildSvg(templateSvg, brand, {
|
const svg = buildSvg(templateSvg, brand, {
|
||||||
template: "cover",
|
template: "cover",
|
||||||
title: "With Icon",
|
title: "With Icon",
|
||||||
|
|
@ -67,7 +68,7 @@ describe("integration — slot resolution", () => {
|
||||||
|
|
||||||
test("buildSvg without slots renders template defaults", () => {
|
test("buildSvg without slots renders template defaults", () => {
|
||||||
const brand = loadBrand(DRAW_IMAGE_DIR)
|
const brand = loadBrand(DRAW_IMAGE_DIR)
|
||||||
const templateSvg = loadTemplate(DRAW_IMAGE_DIR, "cover")
|
const templateSvg = loadFixture("cover")
|
||||||
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Icon" }, DRAW_IMAGE_DIR)
|
const svg = buildSvg(templateSvg, brand, { template: "cover", title: "No Icon" }, DRAW_IMAGE_DIR)
|
||||||
expect(svg).toContain("No Icon")
|
expect(svg).toContain("No Icon")
|
||||||
expect(svg).not.toContain("<!-- slot:")
|
expect(svg).not.toContain("<!-- slot:")
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,53 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ollama-cloud": {
|
||||||
|
"models": {
|
||||||
|
"deepseek-v4-flash:0731": {
|
||||||
|
"name": "DS4F-0731",
|
||||||
|
"reasoning": true,
|
||||||
|
"modalities": {
|
||||||
|
"input": [
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"context": 200000,
|
||||||
|
"input": 200000,
|
||||||
|
"output": 65536
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"reasoningEffort": "max"
|
||||||
|
},
|
||||||
|
"variants": {
|
||||||
|
"max": {
|
||||||
|
"reasoningEffort": "max"
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"minimal": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"low": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"medium": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"high": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"xhigh": {
|
||||||
|
"disabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
|
|
@ -189,6 +236,11 @@
|
||||||
"python3 */spec-status.py*": "deny",
|
"python3 */spec-status.py*": "deny",
|
||||||
"python *spec-status.py*": "deny",
|
"python *spec-status.py*": "deny",
|
||||||
"python */spec-status.py*": "deny",
|
"python */spec-status.py*": "deny",
|
||||||
|
"python3 *project-status.py*": "deny",
|
||||||
|
"python3 .opencode/scripts/project-status.py*": "deny",
|
||||||
|
"python3 */project-status.py*": "deny",
|
||||||
|
"python *project-status.py*": "deny",
|
||||||
|
"python */project-status.py*": "deny",
|
||||||
"mkdir*": "allow",
|
"mkdir*": "allow",
|
||||||
"git branch -D *": "ask",
|
"git branch -D *": "ask",
|
||||||
"git branch -d *": "ask",
|
"git branch -d *": "ask",
|
||||||
|
|
@ -276,7 +328,26 @@
|
||||||
"git commit *": "deny",
|
"git commit *": "deny",
|
||||||
"gh pr create *": "deny",
|
"gh pr create *": "deny",
|
||||||
"gh pr merge *": "deny",
|
"gh pr merge *": "deny",
|
||||||
"gh issue create *": "deny"
|
"gh issue create *": "deny",
|
||||||
|
|
||||||
|
"gh api * -X DELETE *": "deny",
|
||||||
|
"gh api -X DELETE *": "deny",
|
||||||
|
"gh api * --method DELETE *": "deny",
|
||||||
|
"gh api * --method delete *": "deny",
|
||||||
|
"gh repo transfer *": "deny",
|
||||||
|
|
||||||
|
"git push --force*": "ask",
|
||||||
|
"git push -f*": "ask",
|
||||||
|
"git push * --force*": "ask",
|
||||||
|
"git push * -f*": "ask",
|
||||||
|
"git push * :*": "ask",
|
||||||
|
"git tag -d *": "ask",
|
||||||
|
"git -C * push --force*": "ask",
|
||||||
|
"git -C * push -f*": "ask",
|
||||||
|
"git -C * push * --force*": "ask",
|
||||||
|
"git -C * push * -f*": "ask",
|
||||||
|
"git -C * push * :*": "ask",
|
||||||
|
"git -C * tag -d *": "ask"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
|
|
@ -287,8 +358,7 @@
|
||||||
"create_pr": true,
|
"create_pr": true,
|
||||||
"create_issue": true,
|
"create_issue": true,
|
||||||
"merge_pr": false,
|
"merge_pr": false,
|
||||||
"post_review": false,
|
"post_review": false
|
||||||
"post_docs_review": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reviewer": {
|
"reviewer": {
|
||||||
|
|
@ -297,18 +367,7 @@
|
||||||
"create_pr": false,
|
"create_pr": false,
|
||||||
"create_issue": true,
|
"create_issue": true,
|
||||||
"merge_pr": false,
|
"merge_pr": false,
|
||||||
"post_review": true,
|
"post_review": true
|
||||||
"post_docs_review": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"docs-reviewer": {
|
|
||||||
"tools": {
|
|
||||||
"commit": true,
|
|
||||||
"create_pr": false,
|
|
||||||
"create_issue": true,
|
|
||||||
"merge_pr": false,
|
|
||||||
"post_review": false,
|
|
||||||
"post_docs_review": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"memory-syncer": {
|
"memory-syncer": {
|
||||||
|
|
@ -317,8 +376,7 @@
|
||||||
"create_pr": false,
|
"create_pr": false,
|
||||||
"create_issue": true,
|
"create_issue": true,
|
||||||
"merge_pr": false,
|
"merge_pr": false,
|
||||||
"post_review": false,
|
"post_review": false
|
||||||
"post_docs_review": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Check ADR references in markdown files for dangling pointers.
|
|
||||||
|
|
||||||
Scans ``.md`` files in the repo for ``ADR-NNN`` references and verifies
|
|
||||||
that a corresponding ``docs/decisions/NNN-*.md`` file exists. Dangling
|
|
||||||
references (typos, forward-refs) fail CI — symmetric to
|
|
||||||
``check-permissions.py`` (ADR-006 pattern).
|
|
||||||
|
|
||||||
Self-reference is excluded: if the current file's name starts with
|
|
||||||
``NNN-``, a reference to ``ADR-NNN`` inside it is OK (an ADR file may
|
|
||||||
mention its own number).
|
|
||||||
|
|
||||||
What is NOT caught: wrong existing refs (``ADR-017`` exists but is
|
|
||||||
semantically wrong for a given PR) — that requires semantic analysis.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
ADR_DIR = REPO_ROOT / "docs" / "decisions"
|
|
||||||
ADR_REF_RE = re.compile(r"\bADR-(\d{3})\b")
|
|
||||||
|
|
||||||
EXCLUDE_DIRS = {"node_modules", ".git", "app_data", ".opencode"}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_excluded(path: Path) -> bool:
|
|
||||||
"""True if ``path`` is inside an excluded directory (node_modules, .git, ...)."""
|
|
||||||
try:
|
|
||||||
rel = path.relative_to(REPO_ROOT)
|
|
||||||
except ValueError:
|
|
||||||
return True
|
|
||||||
parts = rel.parts
|
|
||||||
return any(excl in parts for excl in EXCLUDE_DIRS)
|
|
||||||
|
|
||||||
|
|
||||||
def find_md_files() -> list[Path]:
|
|
||||||
"""Return all ``.md`` files under REPO_ROOT, excluding node_modules/.git/etc."""
|
|
||||||
results: list[Path] = []
|
|
||||||
for md_file in REPO_ROOT.rglob("*.md"):
|
|
||||||
if _is_excluded(md_file):
|
|
||||||
continue
|
|
||||||
results.append(md_file)
|
|
||||||
return sorted(results)
|
|
||||||
|
|
||||||
|
|
||||||
def find_adr_refs(md_file: Path) -> list[tuple[int, str]]:
|
|
||||||
"""Return ``[(line_number, adr_number), ...]`` for every ``ADR-NNN`` in ``md_file``."""
|
|
||||||
refs: list[tuple[int, str]] = []
|
|
||||||
text = md_file.read_text(encoding="utf-8", errors="replace")
|
|
||||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
||||||
for match in ADR_REF_RE.finditer(line):
|
|
||||||
refs.append((line_no, match.group(1)))
|
|
||||||
return refs
|
|
||||||
|
|
||||||
|
|
||||||
def is_self_reference(md_file: Path, adr_number: str) -> bool:
|
|
||||||
"""True if ``md_file``'s name starts with ``NNN-`` where NNN == adr_number."""
|
|
||||||
return md_file.name.startswith(f"{adr_number}-")
|
|
||||||
|
|
||||||
|
|
||||||
def adr_exists(adr_number: str) -> bool:
|
|
||||||
"""True if ``docs/decisions/NNN-*.md`` exists for the given number."""
|
|
||||||
if not ADR_DIR.exists():
|
|
||||||
return False
|
|
||||||
return any(ADR_DIR.glob(f"{adr_number}-*.md"))
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
all_violations: list[str] = []
|
|
||||||
for md_file in find_md_files():
|
|
||||||
refs = find_adr_refs(md_file)
|
|
||||||
for ref_line, ref_number in refs:
|
|
||||||
if is_self_reference(md_file, ref_number):
|
|
||||||
continue
|
|
||||||
if not adr_exists(ref_number):
|
|
||||||
all_violations.append(
|
|
||||||
f" {md_file.relative_to(REPO_ROOT)}:{ref_line}: "
|
|
||||||
f"ADR-{ref_number} reference, but "
|
|
||||||
f"docs/decisions/{ref_number}-*.md does not exist"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not all_violations:
|
|
||||||
print("OK: No dangling ADR references.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
print("FAIL: Dangling ADR references:\n")
|
|
||||||
for v in all_violations:
|
|
||||||
print(v)
|
|
||||||
print(f"\nTotal: {len(all_violations)} violation(s)")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Pipeline-status oracle: determine PR phase in 7-phase PR pipeline.
|
"""Pipeline-status oracle: determine PR phase in 6-phase PR pipeline.
|
||||||
|
|
||||||
Reads facts from GitHub (gh CLI), git, and memory files to deterministically
|
Reads facts from GitHub (gh CLI), git, and memory files to deterministically
|
||||||
derive the current pipeline phase of a PR — no state file, like ``git status``
|
derive the current pipeline phase of a PR — no state file, like ``git status``
|
||||||
|
|
@ -10,24 +10,26 @@ Usage:
|
||||||
python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status
|
python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status
|
||||||
python3 config/scripts/pipeline-status.py # table of open PRs
|
python3 config/scripts/pipeline-status.py # table of open PRs
|
||||||
|
|
||||||
Seven phases:
|
Six phases:
|
||||||
1. ISSUE — GitHub issue exists and linked via Closes/Fixes #N
|
1. ISSUE — GitHub issue exists and linked via Closes/Fixes #N
|
||||||
2. IMPLEMENT — PR exists + handoff file docs/handoff/pr-N-slug.md in diff
|
2. IMPLEMENT — PR exists + PR body has 4 required headings
|
||||||
3. DOCS — handoff valid (4 sections) + mandatory ADR
|
3. CI — all checks on PR head SHA completed & success (statusCheckRollup)
|
||||||
4. CI — all checks on PR head SHA completed & success (statusCheckRollup)
|
4. REVIEW — APPROVE found in PR comments
|
||||||
5. REVIEW — APPROVE found in PR comments
|
5. MERGE — PR state is MERGED
|
||||||
6. MERGE — PR state is MERGED
|
6. MEMORY — PR#N distilled into repos/{host}/{org}/{repo}.md
|
||||||
7. MEMORY — PR#N distilled into repos/{host}/{org}/{repo}.md
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -49,13 +51,8 @@ _MEMORY_BASE = os.environ.get(
|
||||||
str(REPO_ROOT / "app_data" / "opencode-memory"),
|
str(REPO_ROOT / "app_data" / "opencode-memory"),
|
||||||
)
|
)
|
||||||
MEMORY_DIR = Path(_MEMORY_BASE) / "repos"
|
MEMORY_DIR = Path(_MEMORY_BASE) / "repos"
|
||||||
HANDOFF_DIR = REPO_ROOT / "docs" / "handoff"
|
|
||||||
ADR_DIR = REPO_ROOT / "docs" / "decisions"
|
|
||||||
PROJECT_MAP_DIR = REPO_ROOT / "docs" / "project-map"
|
|
||||||
|
|
||||||
REQUIRED_SECTIONS = ["## Что сделано", "## Почему", "## Pending", "## Watch out"]
|
PHASE_NAMES = ["ISSUE", "IMPLEMENT", "CI", "REVIEW", "MERGE", "MEMORY"]
|
||||||
|
|
||||||
PHASE_NAMES = ["ISSUE", "IMPLEMENT", "DOCS", "CI", "REVIEW", "MERGE", "MEMORY"]
|
|
||||||
|
|
||||||
CI_WAIT_TIMEOUT = 300
|
CI_WAIT_TIMEOUT = 300
|
||||||
CI_POLL_INTERVAL = 10
|
CI_POLL_INTERVAL = 10
|
||||||
|
|
@ -71,7 +68,6 @@ REVIEW_VERDICT_RE = re.compile(
|
||||||
r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)",
|
r"## Code Review Summary.*?###\s*Verdict:\s*(\w+)",
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
DOCS_REVIEW_RE = re.compile(r"Docs Review", re.IGNORECASE)
|
|
||||||
JSON_FIELD_RE = re.compile(r'"(\w+)"\s*:\s*"?([^",}]*)"?', re.IGNORECASE)
|
JSON_FIELD_RE = re.compile(r'"(\w+)"\s*:\s*"?([^",}]*)"?', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,10 +87,135 @@ class PhaseResult:
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def _forgejo_request(method: str, path: str, body: dict | None = None) -> tuple[int, str, str]:
|
||||||
|
"""Forgejo REST API call. Returns (status_code, body_text, error).
|
||||||
|
|
||||||
|
On non-2xx returns (status_code, body_text, ""). ``urlopen`` raises
|
||||||
|
HTTPError for non-2xx which carries the body; we surface it so callers
|
||||||
|
can branch on the HTTP status. Network/parse errors return (0, "", err).
|
||||||
|
"""
|
||||||
|
base = os.environ.get("FORGEJO_URL")
|
||||||
|
token = os.environ.get("FORGEJO_TOKEN")
|
||||||
|
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request( # noqa: S310 - base URL is operator-configured
|
||||||
|
f"{base}/api/v1{path}", method=method, headers=headers, data=data
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r: # noqa: S310 - base URL is operator-configured
|
||||||
|
return r.status, r.read().decode("utf-8", "replace"), ""
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode("utf-8", "replace"), ""
|
||||||
|
except OSError as e:
|
||||||
|
return 0, "", str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _forgejo_ci_rollup(repo: str, sha: str) -> tuple[int, str, str]:
|
||||||
|
"""Build a GitHub-style statusCheckRollup from the Forgejo commit status."""
|
||||||
|
sc, text, err = _forgejo_request("GET", f"/repos/{repo}/commits/{sha}/status")
|
||||||
|
if sc != 200:
|
||||||
|
return 1, "", err or f"commit status HTTP {sc}"
|
||||||
|
combined = json.loads(text)
|
||||||
|
rollup: list[dict] = []
|
||||||
|
for s in combined.get("statuses", []):
|
||||||
|
st = s.get("status", "").lower()
|
||||||
|
if st == "success":
|
||||||
|
rollup.append({"status": "COMPLETED", "conclusion": "SUCCESS"})
|
||||||
|
elif st == "pending":
|
||||||
|
rollup.append({"status": "IN_PROGRESS", "conclusion": ""})
|
||||||
|
elif st in ("failure", "error"):
|
||||||
|
rollup.append({"status": "COMPLETED", "conclusion": "FAILURE"})
|
||||||
|
else:
|
||||||
|
rollup.append({"status": "QUEUED", "conclusion": ""})
|
||||||
|
return 0, json.dumps({"statusCheckRollup": rollup}), ""
|
||||||
|
|
||||||
|
|
||||||
|
def _forgejo_pr_view(repo: str, n: str, fields: str) -> tuple[int, str, str]: # noqa: PLR0911
|
||||||
|
"""Translate ``gh pr view N --json <fields>`` to Forgejo API calls."""
|
||||||
|
rc, pr_text, err = _forgejo_request("GET", f"/repos/{repo}/pulls/{n}")
|
||||||
|
if rc != 200:
|
||||||
|
return 1, "", err or f"PR API HTTP {rc}"
|
||||||
|
pr = json.loads(pr_text)
|
||||||
|
if fields == "statusCheckRollup":
|
||||||
|
return _forgejo_ci_rollup(repo, pr["head"]["sha"])
|
||||||
|
if fields == "comments":
|
||||||
|
sc, comments_text, ce = _forgejo_request("GET", f"/repos/{repo}/issues/{n}/comments")
|
||||||
|
if sc != 200:
|
||||||
|
return 1, "", ce or f"comments HTTP {sc}"
|
||||||
|
comments = json.loads(comments_text)
|
||||||
|
return 0, json.dumps({"comments": [{"body": c.get("body", "")} for c in comments]}), ""
|
||||||
|
if fields == "state":
|
||||||
|
state = "MERGED" if pr.get("merged") else pr.get("state", "").upper()
|
||||||
|
return 0, json.dumps({"state": state}), ""
|
||||||
|
if fields:
|
||||||
|
return 0, json.dumps({fields: pr.get(fields, "")}), ""
|
||||||
|
return 0, pr_text, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _forgejo_pr_dispatch(repo: str, args: list[str]) -> tuple[int, str, str]:
|
||||||
|
"""Translate ``gh pr ...`` argv to Forgejo API calls."""
|
||||||
|
action = args[1] if len(args) > 1 else ""
|
||||||
|
num = args[2] if len(args) > 2 and args[2].lstrip("-").isdigit() else None
|
||||||
|
if action == "view" and num is not None:
|
||||||
|
fields_idx = args.index("--json") if "--json" in args else -1
|
||||||
|
fields = args[fields_idx + 1] if fields_idx >= 0 else ""
|
||||||
|
return _forgejo_pr_view(repo, num, fields)
|
||||||
|
if action == "list":
|
||||||
|
sc, out_text, e = _forgejo_request("GET", f"/repos/{repo}/pulls?state=open")
|
||||||
|
if sc != 200:
|
||||||
|
return 1, "", e or f"pr list HTTP {sc}"
|
||||||
|
pulls = json.loads(out_text)
|
||||||
|
return 0, json.dumps([{"number": p["number"]} for p in pulls]), ""
|
||||||
|
return 1, "", f"gh pr argv {args!r} not supported in Forgejo mode"
|
||||||
|
|
||||||
|
|
||||||
|
def _forgejo_gh_dispatch(args: list[str]) -> tuple[int, str, str] | None: # noqa: PLR0911
|
||||||
|
"""Translate a ``gh`` argv to a Forgejo API call. Returns None to defer.
|
||||||
|
|
||||||
|
Returns ``(rc, stdout, stderr)`` shaped like ``run_cmd`` so callers stay
|
||||||
|
unchanged. Returns ``None`` if ``FORGEJO_URL`` is unset (defer to gh) or
|
||||||
|
the argv is not a supported gh subcommand.
|
||||||
|
"""
|
||||||
|
if not os.environ.get("FORGEJO_URL"):
|
||||||
|
return None
|
||||||
|
if not os.environ.get("FORGEJO_TOKEN"):
|
||||||
|
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
|
||||||
|
if not args or args[0] != "gh":
|
||||||
|
return None
|
||||||
|
repo_idx = args.index("--repo") if "--repo" in args else -1
|
||||||
|
repo = args[repo_idx + 1] if repo_idx >= 0 else None
|
||||||
|
if repo is None:
|
||||||
|
return 1, "", "Forgejo mode requires --repo owner/name"
|
||||||
|
sub = args[1] if len(args) > 1 else ""
|
||||||
|
if sub == "auth" and "status" in args:
|
||||||
|
return 0, "", ""
|
||||||
|
if sub == "pr":
|
||||||
|
return _forgejo_pr_dispatch(repo, args)
|
||||||
|
if sub == "issue" and len(args) > 2 and args[2].lstrip("-").isdigit():
|
||||||
|
sc, out_text, e = _forgejo_request("GET", f"/repos/{repo}/issues/{args[2]}")
|
||||||
|
if sc == 200:
|
||||||
|
return 0, out_text, ""
|
||||||
|
return 1, "", e or f"issue HTTP {sc}"
|
||||||
|
return 1, "", f"gh argv {args!r} not supported in Forgejo mode"
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||||||
"""Run a command, return (returncode, stdout, stderr)."""
|
"""Run a command, return (returncode, stdout, stderr).
|
||||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
|
||||||
return result.returncode, result.stdout, result.stderr
|
Forgejo dispatch (ADR-forgejo): when ``FORGEJO_URL`` is set, ``gh`` argv
|
||||||
|
is translated to a Forgejo REST API call instead of spawning ``gh``.
|
||||||
|
GitHub users (no ``FORGEJO_URL``) see byte-identical behaviour — the
|
||||||
|
``gh`` / ``git`` subprocess path is untouched.
|
||||||
|
"""
|
||||||
|
if args and args[0] == "gh":
|
||||||
|
result = _forgejo_gh_dispatch(args)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
proc = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||||
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -179,13 +300,23 @@ def parse_remote_url(url: str) -> tuple[str, str, str]:
|
||||||
raise ValueError(f"Cannot parse remote URL: {url}")
|
raise ValueError(f"Cannot parse remote URL: {url}")
|
||||||
|
|
||||||
|
|
||||||
def get_memory_file_path() -> Path:
|
def _resolve_memory_base() -> tuple[Path, str]:
|
||||||
"""Derive memory file path from ``git remote get-url origin``."""
|
"""Derive (memory_dir, repo_name) from ``git remote get-url origin``."""
|
||||||
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
|
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
|
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
|
||||||
host, org, repo = parse_remote_url(out.strip())
|
host, org, repo = parse_remote_url(out.strip())
|
||||||
return MEMORY_DIR / host / org / f"{repo}.md"
|
return MEMORY_DIR / host / org, repo
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_files() -> list[Path]:
|
||||||
|
"""All ``repo*.md`` sorted by mtime descending (newest first)."""
|
||||||
|
base_dir, repo = _resolve_memory_base()
|
||||||
|
if not base_dir.exists():
|
||||||
|
return []
|
||||||
|
rot_pattern = re.compile(rf"^{re.escape(repo)}(-\d+)?$")
|
||||||
|
files = [f for f in base_dir.glob("*.md") if rot_pattern.fullmatch(f.stem)]
|
||||||
|
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
@functools.cache
|
@functools.cache
|
||||||
|
|
@ -248,114 +379,29 @@ def check_issue(pr_number: int) -> PhaseResult:
|
||||||
|
|
||||||
|
|
||||||
def check_implement(pr_number: int) -> PhaseResult:
|
def check_implement(pr_number: int) -> PhaseResult:
|
||||||
"""Phase 2: IMPLEMENT — PR exists + handoff file in diff."""
|
"""Phase 2: IMPLEMENT — PR exists + PR body has 4 required headings."""
|
||||||
rc, out, _ = run_cmd(
|
rc, out, _ = run_cmd(
|
||||||
["gh", "pr", "view", str(pr_number), "--json", "files", "--repo", get_repo_full_name()]
|
["gh", "pr", "view", str(pr_number), "--json", "body", "--repo", get_repo_full_name()]
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
|
return PhaseResult(PhaseStatus.NOT_DONE, f"PR #{pr_number} не существует")
|
||||||
|
try:
|
||||||
files = re.findall(r'"path"\s*:\s*"([^"]+)"', out)
|
pr_body = json.loads(out).get("body", "") or ""
|
||||||
pattern = f"docs/handoff/pr-{pr_number}-"
|
except (json.JSONDecodeError, ValueError):
|
||||||
handoff_files = [f for f in files if pattern in f]
|
return PhaseResult(
|
||||||
if not handoff_files:
|
PhaseStatus.NOT_DONE, f"PR #{pr_number}: не удалось распарсить JSON body"
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff {pattern}*.md не найден в diff")
|
)
|
||||||
|
required = ["## Что сделано", "## Почему", "## Watch out", "## Pending"]
|
||||||
return PhaseResult(PhaseStatus.DONE, f"handoff: {Path(handoff_files[0]).name}")
|
missing = [h for h in required if h not in pr_body]
|
||||||
|
|
||||||
|
|
||||||
def check_docs(pr_number: int) -> PhaseResult:
|
|
||||||
"""Phase 3: DOCS — handoff valid (4 sections) + mandatory ADR + project map
|
|
||||||
+ docs-reviewer marker.
|
|
||||||
|
|
||||||
Deterministic checks:
|
|
||||||
1. Handoff file `docs/handoff/pr-N-*.md` exists.
|
|
||||||
2. 4 sections present in handoff content.
|
|
||||||
3. ADR file `docs/decisions/*-pr-N-*.md` exists.
|
|
||||||
4. Project map `docs/project-map/README.md` exists (ADR-028 contract).
|
|
||||||
5. PR comment with heading `## Docs Review Summary` from docs-reviewer (proves it ran).
|
|
||||||
|
|
||||||
Without the docs-reviewer comment, DOCS is NOT_DONE — subagent may have written
|
|
||||||
handoff+ADR without docs-reviewer actually validating them. Symmetric to
|
|
||||||
``check_review`` which looks for ``## Code Review Summary`` in PR comments.
|
|
||||||
"""
|
|
||||||
handoff_files = sorted(HANDOFF_DIR.glob(f"pr-{pr_number}-*.md"))
|
|
||||||
if not handoff_files:
|
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, f"handoff pr-{pr_number}-*.md не найден")
|
|
||||||
|
|
||||||
content = handoff_files[0].read_text()
|
|
||||||
missing = [s for s in REQUIRED_SECTIONS if s not in content]
|
|
||||||
if missing:
|
if missing:
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, f"отсутствуют секции: {', '.join(missing)}")
|
|
||||||
|
|
||||||
adr_result = check_adr(pr_number)
|
|
||||||
if adr_result.status != PhaseStatus.DONE:
|
|
||||||
return adr_result
|
|
||||||
|
|
||||||
pm_result = check_project_map()
|
|
||||||
if pm_result.status != PhaseStatus.DONE:
|
|
||||||
return pm_result
|
|
||||||
|
|
||||||
return _check_docs_reviewer_comment(pr_number)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_docs_reviewer_comment(pr_number: int) -> PhaseResult:
|
|
||||||
"""Phase 3 part: PR comment with 'Docs Review' heading proves docs-reviewer ran."""
|
|
||||||
rc, out, _ = run_cmd(
|
|
||||||
["gh", "pr", "view", str(pr_number), "--json", "comments", "--repo", get_repo_full_name()]
|
|
||||||
)
|
|
||||||
if rc != 0:
|
|
||||||
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось получить комментарии PR")
|
|
||||||
|
|
||||||
comment_bodies = _extract_comment_bodies(out)
|
|
||||||
for body in comment_bodies:
|
|
||||||
if DOCS_REVIEW_RE.search(body):
|
|
||||||
return PhaseResult(PhaseStatus.DONE, "handoff валиден, ADR, docs-review отработал")
|
|
||||||
|
|
||||||
return PhaseResult(
|
return PhaseResult(
|
||||||
PhaseStatus.NOT_DONE,
|
PhaseStatus.NOT_DONE, f"PR body не содержит heading'и: {', '.join(missing)}"
|
||||||
"docs-reviewer не запущен — запусти @docs-reviewer (pre-merge)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_adr(pr_number: int) -> PhaseResult:
|
|
||||||
"""Phase 3 part: ADR is mandatory for every PR. Find by PR# in filename."""
|
|
||||||
pattern = f"*-pr-{pr_number}-*.md"
|
|
||||||
adr_files = sorted(ADR_DIR.glob(pattern)) if ADR_DIR.exists() else []
|
|
||||||
if adr_files:
|
|
||||||
return PhaseResult(PhaseStatus.DONE, f"ADR: {adr_files[0].name}")
|
|
||||||
return PhaseResult(
|
|
||||||
PhaseStatus.NOT_DONE,
|
|
||||||
f"ADR *-pr-{pr_number}-*.md не найден. "
|
|
||||||
f"Создай через bash .opencode/scripts/scaffold-handoff.sh {pr_number} <slug>",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_project_map() -> PhaseResult:
|
|
||||||
"""Phase 3 part: docs/project-map/README.md exists (mandatory project map).
|
|
||||||
|
|
||||||
Minimal guard against silent docs-reviewer failure. Does NOT validate
|
|
||||||
frontmatter or module-level files — see ADR-028 for the contract.
|
|
||||||
"""
|
|
||||||
if not PROJECT_MAP_DIR.exists():
|
|
||||||
return PhaseResult(
|
|
||||||
PhaseStatus.NOT_DONE,
|
|
||||||
f"директория {PROJECT_MAP_DIR} не найдена — "
|
|
||||||
"docs-reviewer должен создать initial README.md (docs-reviewer.md:82-84)",
|
|
||||||
)
|
|
||||||
readme = PROJECT_MAP_DIR / "README.md"
|
|
||||||
if readme.exists():
|
|
||||||
size = readme.stat().st_size
|
|
||||||
return PhaseResult(PhaseStatus.DONE, f"project map: README.md ({size} bytes)")
|
|
||||||
return PhaseResult(
|
|
||||||
PhaseStatus.NOT_DONE,
|
|
||||||
"docs/project-map/README.md не найден — "
|
|
||||||
"docs-reviewer должен создать initial map (docs-reviewer.md:244)",
|
|
||||||
)
|
)
|
||||||
|
return PhaseResult(PhaseStatus.DONE, "PR body: 4 heading'а валидны")
|
||||||
|
|
||||||
|
|
||||||
def check_ci(pr_number: int) -> PhaseResult:
|
def check_ci(pr_number: int) -> PhaseResult:
|
||||||
"""Phase 4: CI — all checks on PR head SHA completed & success.
|
"""Phase 3: CI — all checks on PR head SHA completed & success.
|
||||||
|
|
||||||
Uses ``gh pr view --json statusCheckRollup`` which aggregates ALL
|
Uses ``gh pr view --json statusCheckRollup`` which aggregates ALL
|
||||||
workflows for the PR head SHA (CI, CI (always), ADR check, etc.).
|
workflows for the PR head SHA (CI, CI (always), ADR check, etc.).
|
||||||
|
|
@ -499,7 +545,7 @@ def _extract_comment_bodies(json_str: str) -> list[str]:
|
||||||
def check_review(pr_number: int) -> PhaseResult:
|
def check_review(pr_number: int) -> PhaseResult:
|
||||||
"""Phase 5: REVIEW — APPROVE found in PR comments from code reviewer.
|
"""Phase 5: REVIEW — APPROVE found in PR comments from code reviewer.
|
||||||
|
|
||||||
Looks for '## Code Review Summary' heading (NOT '## Docs Review Summary')
|
Looks for '## Code Review Summary' heading
|
||||||
with '### Verdict: APPROVE'. Only the latest reviewer comment counts —
|
with '### Verdict: APPROVE'. Only the latest reviewer comment counts —
|
||||||
if reviewer changed from APPROVE to REQUEST_CHANGES, NOT_DONE.
|
if reviewer changed from APPROVE to REQUEST_CHANGES, NOT_DONE.
|
||||||
"""
|
"""
|
||||||
|
|
@ -549,24 +595,24 @@ def check_merge(pr_number: int) -> PhaseResult:
|
||||||
def check_memory(pr_number: int) -> PhaseResult:
|
def check_memory(pr_number: int) -> PhaseResult:
|
||||||
"""Phase 7: MEMORY — PR#N distilled into memory file."""
|
"""Phase 7: MEMORY — PR#N distilled into memory file."""
|
||||||
try:
|
try:
|
||||||
memory_file = get_memory_file_path()
|
files = get_memory_files()
|
||||||
except (RuntimeError, ValueError) as exc:
|
except (RuntimeError, ValueError) as exc:
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, str(exc))
|
return PhaseResult(PhaseStatus.NOT_DONE, str(exc))
|
||||||
|
|
||||||
if not memory_file.exists():
|
if not files:
|
||||||
return PhaseResult(
|
return PhaseResult(
|
||||||
PhaseStatus.NOT_DONE,
|
PhaseStatus.NOT_DONE,
|
||||||
f"memory file не существует: {memory_file.name}",
|
"memory files не найдены",
|
||||||
)
|
)
|
||||||
|
|
||||||
content = memory_file.read_text()
|
|
||||||
pattern = f"PR#{pr_number}"
|
pattern = f"PR#{pr_number}"
|
||||||
if pattern in content:
|
for f in files:
|
||||||
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {memory_file.name}")
|
if pattern in f.read_text():
|
||||||
|
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {f.name}")
|
||||||
|
|
||||||
return PhaseResult(
|
return PhaseResult(
|
||||||
PhaseStatus.NOT_DONE,
|
PhaseStatus.NOT_DONE,
|
||||||
f"{pattern} не найден в {memory_file.name}",
|
f"{pattern} не найден в {len(files)} файл(ах)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -584,7 +630,6 @@ def get_pr_title(pr_number: int) -> str:
|
||||||
NEXT_ACTIONS: dict[str, str] = {
|
NEXT_ACTIONS: dict[str, str] = {
|
||||||
"ISSUE": "dispatch subagent (subagent_type=general, template=A) for PR #N",
|
"ISSUE": "dispatch subagent (subagent_type=general, template=A) for PR #N",
|
||||||
"IMPLEMENT": "dispatch subagent (subagent_type=general, template=A) for PR #N",
|
"IMPLEMENT": "dispatch subagent (subagent_type=general, template=A) for PR #N",
|
||||||
"DOCS": "dispatch subagent (subagent_type=docs-reviewer, template=B) for PR #N",
|
|
||||||
"CI": "проверь статус CI вручную (gh run view)",
|
"CI": "проверь статус CI вручную (gh run view)",
|
||||||
"REVIEW": "dispatch subagent (subagent_type=reviewer, template=C) for PR #N",
|
"REVIEW": "dispatch subagent (subagent_type=reviewer, template=C) for PR #N",
|
||||||
"MERGE": "call merge_pr tool with pr_number=N",
|
"MERGE": "call merge_pr tool with pr_number=N",
|
||||||
|
|
@ -622,11 +667,10 @@ def get_next_action_review(result: PhaseResult) -> str:
|
||||||
|
|
||||||
|
|
||||||
def run_all_checks(pr_number: int) -> list[PhaseResult]:
|
def run_all_checks(pr_number: int) -> list[PhaseResult]:
|
||||||
"""Run all 7 phase checks, return results in order."""
|
"""Run all 6 phase checks, return results in order."""
|
||||||
return [
|
return [
|
||||||
check_issue(pr_number),
|
check_issue(pr_number),
|
||||||
check_implement(pr_number),
|
check_implement(pr_number),
|
||||||
check_docs(pr_number),
|
|
||||||
check_ci(pr_number),
|
check_ci(pr_number),
|
||||||
check_review(pr_number),
|
check_review(pr_number),
|
||||||
check_merge(pr_number),
|
check_merge(pr_number),
|
||||||
|
|
|
||||||
1948
.opencode/scripts/project-status.py
Normal file
1948
.opencode/scripts/project-status.py
Normal file
File diff suppressed because it is too large
Load diff
96
.opencode/scripts/project_contract.py
Normal file
96
.opencode/scripts/project_contract.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Project contract: single source of truth for project types, stacks,
|
||||||
|
expected structure, frontend markers.
|
||||||
|
|
||||||
|
Imported by both ``spec-status.py`` and ``project-status.py`` via
|
||||||
|
``importlib.util.spec_from_file_location`` (no ``sys.path`` mutation).
|
||||||
|
Stdlib-only imports.
|
||||||
|
|
||||||
|
Contract symbols:
|
||||||
|
ProjectType — StrEnum with 7 members (incl. MCP_SERVER + UNKNOWN)
|
||||||
|
VALID_TYPES — set[str] excluding "unknown"
|
||||||
|
STACK_REQUIRED — dict[type -> list[str]] of mandatory stack items
|
||||||
|
STRUCTURE_EXPECTED — dict[type -> list[str]] of expected top-level dirs/files
|
||||||
|
FRONTEND_STACK_MARKERS — dict with keys for fullstack frontend detection
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectType(StrEnum):
|
||||||
|
"""Project type enum (auto-detected or spec-declared)."""
|
||||||
|
|
||||||
|
FULLSTACK = "fullstack"
|
||||||
|
BACKEND = "backend"
|
||||||
|
CLI = "cli"
|
||||||
|
BOT = "bot"
|
||||||
|
WORKER = "worker"
|
||||||
|
MCP_SERVER = "mcp-server"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# Excludes "unknown" — used by spec-status PROJECT_TYPE phase validation.
|
||||||
|
VALID_TYPES: set[str] = {t.value for t in ProjectType if t != ProjectType.UNKNOWN}
|
||||||
|
|
||||||
|
# Mandatory stack items per project type (spec-status Phase 2 STACK).
|
||||||
|
# `uv` stays in ALL stacks (build-tool, mentioned in stack.md).
|
||||||
|
STACK_REQUIRED: dict[str, list[str]] = {
|
||||||
|
"backend": ["fastapi", "tortoise", "uv", "pytest", "ruff", "mypy", "loguru", "pydantic"],
|
||||||
|
"fullstack": [
|
||||||
|
"fastapi",
|
||||||
|
"tortoise",
|
||||||
|
"svelte",
|
||||||
|
"sveltekit",
|
||||||
|
"biome",
|
||||||
|
"uv",
|
||||||
|
"ruff",
|
||||||
|
"mypy",
|
||||||
|
"pytest",
|
||||||
|
"tailwind",
|
||||||
|
"shadcn",
|
||||||
|
"typescript",
|
||||||
|
"mobile-first",
|
||||||
|
],
|
||||||
|
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
|
||||||
|
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],
|
||||||
|
"bot": ["aiogram", "fastapi", "uv", "ruff", "mypy", "pytest"],
|
||||||
|
"worker": ["prefect", "uv", "ruff", "mypy", "pytest"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expected top-level structure per type (project-status check_structure).
|
||||||
|
# BACKEND is resolved dynamically by ``_expected_backend_paths`` (src/<pkg>/...),
|
||||||
|
# so it is NOT in this dict. mcp-server structure is TBD out of scope.
|
||||||
|
# Keys are strings (project type values), NOT ProjectType enum members —
|
||||||
|
# kept as plain strings for portability across both oracles.
|
||||||
|
STRUCTURE_EXPECTED: dict[str, list[str]] = {
|
||||||
|
"fullstack": ["backend", "frontend"],
|
||||||
|
"cli": ["src"], # src/<package>/ — checked generically
|
||||||
|
"bot": ["src/bot.py"],
|
||||||
|
"worker": ["src/flow.py"],
|
||||||
|
"unknown": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fullstack frontend stack markers (project-status _check_type_specific_structure).
|
||||||
|
# `tailwindcss` + `bits-ui` (shadcn-svelte proxy) in package.json deps, plus
|
||||||
|
# `components.json` (shadcn config) and `tsconfig.json` (TypeScript) existence.
|
||||||
|
FRONTEND_STACK_MARKERS: dict[str, list[str]] = {
|
||||||
|
"fullstack_package_deps": ["tailwindcss", "bits-ui"],
|
||||||
|
"fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fullstack mobile-first markers (project-status _check_mobile_first, issue #278).
|
||||||
|
# Distinct from FRONTEND_STACK_MARKERS so the stack check stays focused on the
|
||||||
|
# Tailwind/shadcn/TS trio. Keys:
|
||||||
|
# fullstack_files — paths (relative to repo root) that must exist
|
||||||
|
# for PWA + mobile Playwright + a11y to be present.
|
||||||
|
# fullstack_app_html_markers — substrings that must appear in app.html <head>.
|
||||||
|
MOBILE_FIRST_MARKERS: dict[str, list[str]] = {
|
||||||
|
"fullstack_files": [
|
||||||
|
"frontend/static/manifest.webmanifest",
|
||||||
|
"frontend/tests/e2e/mobile.spec.ts",
|
||||||
|
"frontend/tests/e2e/accessibility.spec.ts",
|
||||||
|
],
|
||||||
|
"fullstack_app_html_markers": ["viewport", "manifest"],
|
||||||
|
}
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Create handoff + ADR templates for a PR.
|
|
||||||
# Usage: bash config/scripts/scaffold-handoff.sh <PR#> <slug>
|
|
||||||
# Idempotent: does not overwrite existing files.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PR="${1:?Usage: scaffold-handoff.sh <PR#> <slug>}"
|
|
||||||
SLUG="${2:?Usage: scaffold-handoff.sh <PR#> <slug>}"
|
|
||||||
|
|
||||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
|
|
||||||
if [ -z "$REPO_ROOT" ]; then
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
HANDOFF_DIR="$REPO_ROOT/docs/handoff"
|
|
||||||
ADR_DIR="$REPO_ROOT/docs/decisions"
|
|
||||||
mkdir -p "$HANDOFF_DIR" "$ADR_DIR"
|
|
||||||
|
|
||||||
HANDOFF="$HANDOFF_DIR/pr-${PR}-${SLUG}.md"
|
|
||||||
TODAY="$(date +%Y-%m-%d)"
|
|
||||||
|
|
||||||
if [ -f "$HANDOFF" ]; then
|
|
||||||
echo "handoff already exists: $HANDOFF"
|
|
||||||
else
|
|
||||||
cat > "$HANDOFF" <<EOF
|
|
||||||
---
|
|
||||||
pr: ${PR}
|
|
||||||
title: <заполни>
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
<заполни>
|
|
||||||
|
|
||||||
## Почему
|
|
||||||
<заполни>
|
|
||||||
|
|
||||||
## Pending
|
|
||||||
<заполни, или «—»>
|
|
||||||
|
|
||||||
## Watch out
|
|
||||||
<заполни, или «—»>
|
|
||||||
EOF
|
|
||||||
echo "created: $HANDOFF"
|
|
||||||
fi
|
|
||||||
|
|
||||||
EXISTING_ADR="$(ls "$ADR_DIR"/*-pr-${PR}-${SLUG}.md 2>/dev/null | head -n1 || true)"
|
|
||||||
if [ -n "$EXISTING_ADR" ]; then
|
|
||||||
ADR="$EXISTING_ADR"
|
|
||||||
echo "ADR already exists: $ADR"
|
|
||||||
else
|
|
||||||
NEXT_N="$(ls "$ADR_DIR" 2>/dev/null | grep -E '^[0-9]{3}-' | wc -l | awk '{print $1+1}')"
|
|
||||||
NN="$(printf "%03d" "$NEXT_N")"
|
|
||||||
ADR="$ADR_DIR/${NN}-pr-${PR}-${SLUG}.md"
|
|
||||||
cat > "$ADR" <<EOF
|
|
||||||
# ADR-${NN}: <title>
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
Accepted (${TODAY})
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
<заполни, или «—» если архитектурных решений не было>
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
<заполни, или «—»>
|
|
||||||
|
|
||||||
## Альтернативы
|
|
||||||
<заполни, или «—»>
|
|
||||||
EOF
|
|
||||||
echo "created: $ADR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "handoff: $HANDOFF"
|
|
||||||
echo "adr: $ADR"
|
|
||||||
|
|
@ -37,13 +37,23 @@ Nine phases:
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Load project_contract.py via importlib.util (no sys.path mutation).
|
||||||
|
_contract_path = Path(__file__).resolve().parent / "project_contract.py"
|
||||||
|
_pc_spec = importlib.util.spec_from_file_location("project_contract", _contract_path)
|
||||||
|
project_contract = importlib.util.module_from_spec(_pc_spec) # type: ignore[arg-type]
|
||||||
|
_pc_spec.loader.exec_module(project_contract) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
def _resolve_repo_root() -> Path:
|
def _resolve_repo_root() -> Path:
|
||||||
"""Resolve repo root via git (cwd-aware), fallback to script location."""
|
"""Resolve repo root via git (cwd-aware), fallback to script location."""
|
||||||
|
|
@ -67,16 +77,10 @@ PHASE_FILES: dict[int, str] = {
|
||||||
6: "roadmap.md",
|
6: "roadmap.md",
|
||||||
}
|
}
|
||||||
|
|
||||||
VALID_TYPES = {"backend", "fullstack", "mcp-server", "cli", "bot", "worker"}
|
# Re-exported from project_contract.py for backward compatibility
|
||||||
|
# (tests use ``ss.VALID_TYPES`` / ``ss.STACK_REQUIRED``).
|
||||||
STACK_REQUIRED: dict[str, list[str]] = {
|
VALID_TYPES = project_contract.VALID_TYPES
|
||||||
"backend": ["fastapi", "tortoise", "uv", "pytest", "ruff", "mypy"],
|
STACK_REQUIRED = project_contract.STACK_REQUIRED
|
||||||
"fullstack": ["fastapi", "tortoise", "react", "vite", "biome", "uv"],
|
|
||||||
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
|
|
||||||
"cli": ["typer", "uv", "hatchling"],
|
|
||||||
"bot": ["aiogram", "fastapi", "uv"],
|
|
||||||
"worker": ["prefect", "uv"],
|
|
||||||
}
|
|
||||||
|
|
||||||
PHASE_NAMES = [
|
PHASE_NAMES = [
|
||||||
"DETECT",
|
"DETECT",
|
||||||
|
|
@ -90,7 +94,7 @@ PHASE_NAMES = [
|
||||||
"EXECUTE",
|
"EXECUTE",
|
||||||
]
|
]
|
||||||
|
|
||||||
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
|
FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
|
||||||
KV_RE = re.compile(r"^(\w+):\s*(.*?)$", re.MULTILINE)
|
KV_RE = re.compile(r"^(\w+):\s*(.*?)$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,8 +114,47 @@ class PhaseResult:
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def _forgejo_get(path: str) -> tuple[int, str, str]:
|
||||||
|
"""Forgejo REST GET. Returns (status_code, body_text, error)."""
|
||||||
|
base = os.environ.get("FORGEJO_URL")
|
||||||
|
token = os.environ.get("FORGEJO_TOKEN")
|
||||||
|
req = urllib.request.Request( # noqa: S310 - operator-configured base URL
|
||||||
|
f"{base}/api/v1{path}",
|
||||||
|
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r: # noqa: S310 - operator-configured base URL
|
||||||
|
return r.status, r.read().decode("utf-8", "replace"), ""
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode("utf-8", "replace"), ""
|
||||||
|
except OSError as e:
|
||||||
|
return 0, "", str(e)
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||||||
"""Run a command, return (returncode, stdout, stderr)."""
|
"""Run a command, return (returncode, stdout, stderr).
|
||||||
|
|
||||||
|
Forgejo dispatch: when ``FORGEJO_URL`` is set, ``gh issue view`` is routed
|
||||||
|
to the Forgejo REST API instead of spawning ``gh``. GitHub users (no
|
||||||
|
``FORGEJO_URL``) see byte-identical behaviour — the gh/git subprocess path
|
||||||
|
is untouched.
|
||||||
|
"""
|
||||||
|
if args and args[0] == "gh" and os.environ.get("FORGEJO_URL"):
|
||||||
|
if not os.environ.get("FORGEJO_TOKEN"):
|
||||||
|
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
|
||||||
|
repo_idx = args.index("--repo") if "--repo" in args else -1
|
||||||
|
repo = args[repo_idx + 1] if repo_idx >= 0 else None
|
||||||
|
if (
|
||||||
|
args[1:3] == ["issue", "view"]
|
||||||
|
and repo
|
||||||
|
and len(args) > 2
|
||||||
|
and args[2].lstrip("-").isdigit()
|
||||||
|
):
|
||||||
|
sc, out_text, err = _forgejo_get(f"/repos/{repo}/issues/{args[2]}")
|
||||||
|
if sc == 200:
|
||||||
|
return 0, out_text, ""
|
||||||
|
return 1, "", err or f"issue HTTP {sc}"
|
||||||
|
return 1, "", f"gh argv {args!r} not supported in Forgejo mode"
|
||||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
@ -152,22 +195,34 @@ def get_repo_full_name() -> str:
|
||||||
|
|
||||||
|
|
||||||
def parse_frontmatter(content: str) -> dict[str, str]:
|
def parse_frontmatter(content: str) -> dict[str, str]:
|
||||||
"""Parse simple key: value frontmatter (no nested structures)."""
|
"""Parse simple key: value frontmatter (no nested structures).
|
||||||
|
|
||||||
|
Strips one balanced pair of surrounding single/double quotes from each
|
||||||
|
value (e.g. ``type: 'fullstack'`` -> ``fullstack``). Unbalanced quotes
|
||||||
|
are preserved verbatim.
|
||||||
|
"""
|
||||||
match = FRONTMATTER_RE.search(content)
|
match = FRONTMATTER_RE.search(content)
|
||||||
if not match:
|
if not match:
|
||||||
return {}
|
return {}
|
||||||
fm_text = match.group(1)
|
fm_text = match.group(1)
|
||||||
return dict(KV_RE.findall(fm_text))
|
parsed: dict[str, str] = {}
|
||||||
|
for key, raw in KV_RE.findall(fm_text):
|
||||||
|
value = raw.strip()
|
||||||
|
if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
|
||||||
|
value = value[1:-1]
|
||||||
|
parsed[key] = value
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def read_meta() -> tuple[str, dict[str, str]]:
|
def read_meta() -> tuple[str, dict[str, str]]:
|
||||||
"""Read docs/spec/meta.md content + parsed frontmatter.
|
"""Read docs/spec/meta.md content + parsed frontmatter.
|
||||||
|
|
||||||
Returns ``("", {})`` if meta.md is missing.
|
Returns ``("", {})`` if meta.md is missing. Uses ``utf-8-sig`` to
|
||||||
|
transparently strip a leading BOM if present.
|
||||||
"""
|
"""
|
||||||
if not META_FILE.exists():
|
if not META_FILE.exists():
|
||||||
return "", {}
|
return "", {}
|
||||||
content = META_FILE.read_text()
|
content = META_FILE.read_text(encoding="utf-8-sig")
|
||||||
return content, parse_frontmatter(content)
|
return content, parse_frontmatter(content)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -229,8 +284,13 @@ def check_stack() -> PhaseResult:
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
|
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
|
||||||
required = STACK_REQUIRED[ptype]
|
required = STACK_REQUIRED[ptype]
|
||||||
stack_body = stack_file.read_text()
|
stack_body = stack_file.read_text()
|
||||||
stack_lower = stack_body.lower()
|
# Word-boundary regex: ``uv`` does NOT match ``uvicorn``, ``tailwind``
|
||||||
missing = [item for item in required if item not in stack_lower]
|
# does NOT match ``tailwindcss``. Case-insensitive.
|
||||||
|
missing = [
|
||||||
|
item
|
||||||
|
for item in required
|
||||||
|
if not re.search(rf"\b{re.escape(item)}\b", stack_body, re.IGNORECASE)
|
||||||
|
]
|
||||||
if missing:
|
if missing:
|
||||||
return PhaseResult(
|
return PhaseResult(
|
||||||
PhaseStatus.NOT_DONE,
|
PhaseStatus.NOT_DONE,
|
||||||
|
|
@ -260,7 +320,7 @@ def check_modules() -> PhaseResult:
|
||||||
def check_db_schema() -> PhaseResult:
|
def check_db_schema() -> PhaseResult:
|
||||||
"""Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled."""
|
"""Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled."""
|
||||||
_content, fm = read_meta()
|
_content, fm = read_meta()
|
||||||
if fm.get("no_db", "").strip().lower() in {"true", '"true"'}:
|
if fm.get("no_db", "").strip().lower() == "true":
|
||||||
return PhaseResult(PhaseStatus.DONE, "no_db: true (DB не нужна)")
|
return PhaseResult(PhaseStatus.DONE, "no_db: true (DB не нужна)")
|
||||||
db_file = SPEC_DIR / PHASE_FILES[4]
|
db_file = SPEC_DIR / PHASE_FILES[4]
|
||||||
if not file_filled(db_file):
|
if not file_filled(db_file):
|
||||||
|
|
@ -304,7 +364,7 @@ def check_confirm() -> PhaseResult:
|
||||||
"""Phase 7: CONFIRM — confirmed: true in meta.md frontmatter."""
|
"""Phase 7: CONFIRM — confirmed: true in meta.md frontmatter."""
|
||||||
_content, fm = read_meta()
|
_content, fm = read_meta()
|
||||||
val = fm.get("confirmed", "").strip().lower()
|
val = fm.get("confirmed", "").strip().lower()
|
||||||
if val not in {"true", '"true"'}:
|
if val != "true":
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, "confirmed: true отсутствует в frontmatter")
|
return PhaseResult(PhaseStatus.NOT_DONE, "confirmed: true отсутствует в frontmatter")
|
||||||
return PhaseResult(PhaseStatus.DONE, "spec подтверждён юзером")
|
return PhaseResult(PhaseStatus.DONE, "spec подтверждён юзером")
|
||||||
|
|
||||||
|
|
@ -336,7 +396,7 @@ def check_execute() -> PhaseResult:
|
||||||
"""Phase 8: EXECUTE — executed: true + issues created (gh view --repo)."""
|
"""Phase 8: EXECUTE — executed: true + issues created (gh view --repo)."""
|
||||||
_content, fm = read_meta()
|
_content, fm = read_meta()
|
||||||
val = fm.get("executed", "").strip().lower()
|
val = fm.get("executed", "").strip().lower()
|
||||||
if val not in {"true", '"true"'}:
|
if val != "true":
|
||||||
return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter")
|
return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter")
|
||||||
roadmap_file = SPEC_DIR / PHASE_FILES[6]
|
roadmap_file = SPEC_DIR / PHASE_FILES[6]
|
||||||
if not roadmap_file.exists():
|
if not roadmap_file.exists():
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ description: <when to load this skill, in English. Example: Use when ... Also wh
|
||||||
├── memory/SKILL.md
|
├── memory/SKILL.md
|
||||||
├── python-development/SKILL.md
|
├── python-development/SKILL.md
|
||||||
├── release/SKILL.md
|
├── release/SKILL.md
|
||||||
├── repo-init/SKILL.md
|
├── project-template/SKILL.md
|
||||||
├── run-pipeline/SKILL.md
|
├── run-pipeline/SKILL.md
|
||||||
├── run-tests/SKILL.md
|
├── run-tests/SKILL.md
|
||||||
├── spec/SKILL.md
|
├── spec/SKILL.md
|
||||||
|
|
|
||||||
153
.opencode/skills/audit/SKILL.md
Normal file
153
.opencode/skills/audit/SKILL.md
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
---
|
||||||
|
name: audit
|
||||||
|
description: One-command project audit. project-status tool + explore subagent (code-standards) → binary verdict → ask user → create-issue for each problem. Read-only (creates issues, NOT fixes). Also when user says "аудит проекта", "проверь проект", "audit", "проверь архитектуру".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
|
||||||
|
Линейный flow для аудита существующего проекта. В отличие от
|
||||||
|
`project-template` check flow (который чинит через FIX subagents напрямую),
|
||||||
|
audit создаёт **GitHub issues** для рефакторинга — дальше юзер запускает
|
||||||
|
`/run-pipeline` на каждом issue (pipeline-подход: ISSUE → IMPLEMENT → CI →
|
||||||
|
REVIEW → MERGE).
|
||||||
|
|
||||||
|
Источники находок:
|
||||||
|
- **`project-status` tool** — детерминированные проблемы (нет `conftest.py`,
|
||||||
|
нет `ci.yml`, thin routes, centralized models). Agent парсит текстовый
|
||||||
|
отчёт (НЕ JSON — `project-status.py` не трогаем).
|
||||||
|
- **`explore` subagent + `code-standards` skill** — качественные проблемы
|
||||||
|
(`project-status` НЕ ловит): schemas смешаны с models, бизнес-логика в
|
||||||
|
роутах, нарушение layering, service-слой пропущен.
|
||||||
|
|
||||||
|
## ПРОТОКОЛ (ЖЁСТКО)
|
||||||
|
|
||||||
|
1. **`project-status({})`** — оркестратор вызывает tool напрямую (read-only
|
||||||
|
oracle, ALLOWED — как `pipeline-status` / `spec-status`). Если вернул
|
||||||
|
`⚠️ ...failed` → WARN, продолжай без детерминированных находок (explore
|
||||||
|
всё равно работает).
|
||||||
|
2. **Парсит отчёт** (текст): `Итог:` (OK/WARN counts — issue #275: FAIL убран,
|
||||||
|
exit code всегда 0) + `Рекомендации:` (список WARN с путями). Источник
|
||||||
|
находок = секция `Рекомендации:` (каждая строка `- <group> / <name>: <detail>`).
|
||||||
|
3. **`skill({ name: "code-standards" })`** — load skill (НЕ хардкод правил в
|
||||||
|
audit skill — `code-standards` источник правды).
|
||||||
|
4. **Delegate `explore` subagent** (Template EXPLORE) — проверяет структуру
|
||||||
|
и код против `code-standards`, возвращает
|
||||||
|
`[{category, problem, path, severity}, ...]`.
|
||||||
|
5. **Комбинирует** находки: WARN из `project-status` + качественные из
|
||||||
|
explore. **Дедупликация**: если `project-status` WARN и explore нашли
|
||||||
|
одну и ту же проблему → 1 issue (не 2).
|
||||||
|
6. **Бинарный вердикт** (issue #275: парсит WARN, не exit code — exit всегда 0):
|
||||||
|
- `≥1 WARN` ИЛИ `≥1 qualitative finding` → `❌ Найдено N проблем`
|
||||||
|
- `0 WARN` + `0 qualitative` → `✅ Проект здоров` → STOP
|
||||||
|
7. **Список проблем** (сгруппированный: Структура / Качество / Тесты / Infra
|
||||||
|
/ Code-standards) — покажи юзеру.
|
||||||
|
8. **Вопрос юзеру**:
|
||||||
|
```
|
||||||
|
Найдено N проблем. Создать issues для рефакторинга?
|
||||||
|
[1] да — для каждой проблемы create-issue (subagent)
|
||||||
|
[2] нет — STOP, отчёт у юзера
|
||||||
|
```
|
||||||
|
9. Если `да` → для **каждой** проблемы (последовательно, НЕ параллельно —
|
||||||
|
Linear Execution из AGENTS.md) — delegate `task(general)` с Template
|
||||||
|
ISSUE_CREATE. Если `create-issue` валидация упала → subagent сообщает
|
||||||
|
ошибку, continue к следующей. Если gh недоступен → STOP + report.
|
||||||
|
10. **Финальный репорт**:
|
||||||
|
```
|
||||||
|
Audit complete. Создано N issues:
|
||||||
|
- #M1: <title> — <url>
|
||||||
|
Запусти /run-pipeline на каждом issue для рефакторинга.
|
||||||
|
```
|
||||||
|
|
||||||
|
### ЗАПРЕЩЕНО
|
||||||
|
|
||||||
|
- Чинить код напрямую (audit = read-only, только issues). FIX flow остаётся
|
||||||
|
в `project-template` check flow.
|
||||||
|
- Параллелить create-issue subagents (Linear Execution).
|
||||||
|
- Хардкодить правила из `code-standards` — загружай через `skill()`.
|
||||||
|
- Трогать `project-status.py` (агент парсит текст — JSON не нужен).
|
||||||
|
- Создавать `audit-status` tool (audit — линейный, не фазный loop).
|
||||||
|
- Группировать проблемы в один issue (1 проблема = 1 issue для `/run-pipeline`).
|
||||||
|
- Парсить exit code `project-status` для вердикта (issue #275: exit всегда 0,
|
||||||
|
парси WARN в `Рекомендации:`).
|
||||||
|
|
||||||
|
## Граничные случаи
|
||||||
|
|
||||||
|
- **Репо UNKNOWN типа** → explore всё равно проверяет против
|
||||||
|
`code-standards`, вердикт по качественным находкам.
|
||||||
|
- **Репо без `src/<pkg>/`** (flat layout) → `project-status` WARNs, explore
|
||||||
|
проверяет по `code-standards` (если применимо).
|
||||||
|
- **Только WARN** (0 qualitative) → `❌ Найдено N проблем`, вопрос (да/нет —
|
||||||
|
на усмотрение юзера).
|
||||||
|
- **Юзер "нет"** → STOP, отчёт у юзера.
|
||||||
|
- **create-issue валидация упала** → subagent сообщает, continue к следующей.
|
||||||
|
- **Дублирующие проблемы** → дедупликация оркестратором (1 issue, не 2).
|
||||||
|
|
||||||
|
## Prompt templates
|
||||||
|
|
||||||
|
### Template EXPLORE (code-standards qualitative audit)
|
||||||
|
|
||||||
|
```
|
||||||
|
Прочитай .opencode/skills/code-standards/SKILL.md.
|
||||||
|
Проверь структуру и код проекта в <cwd> против правил из skill.
|
||||||
|
Найди качественные проблемы, которые project-status НЕ ловит:
|
||||||
|
- schemas смешаны с models (Pydantic DTO в db/models/)
|
||||||
|
- бизнес-логика в роутах (Tortoise queries в api/)
|
||||||
|
- нарушение layering (routes импортируют db/models напрямую, минуя services)
|
||||||
|
- service-слой пропущен (routes → db/models без services/)
|
||||||
|
- файлы длиннее 200-300 строк (декомпозиция)
|
||||||
|
- mixing concerns (бизнес-логика ≠ транспорт ≠ представление)
|
||||||
|
- mobile-first missing (fullstack): нет PWA manifest, нет Playwright mobile spec, нет axe a11y spec, нет viewport meta — `STACK_REQUIRED["fullstack"]` требует "mobile-first", но качественно проверь что mobile-first реален, а не просто слово в stack.md
|
||||||
|
|
||||||
|
Для каждой находки верни:
|
||||||
|
{category: "Code-standards", problem: "<name>: <detail>", path: "<file:line>", severity: "warn"|"fail"}
|
||||||
|
|
||||||
|
Верни массив находок. Если находок нет — пустой массив [].
|
||||||
|
НЕ редактируй код — audit read-only. Только отчёт.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template ISSUE_CREATE (create-issue для одной проблемы)
|
||||||
|
|
||||||
|
```
|
||||||
|
Создай GitHub issue для проблемы из audit.
|
||||||
|
Категория: <category>
|
||||||
|
Проблема: <name>: <detail>
|
||||||
|
Путь: <path>
|
||||||
|
Источник: <project-status | code-standards explore>
|
||||||
|
Severity: <warn|fail>
|
||||||
|
|
||||||
|
1. Load `issue` skill via `skill({ name: "issue" })`.
|
||||||
|
2. Сформируй issue body (8 headings: ## Контекст, ## Задача, ## Контракты,
|
||||||
|
## Инварианты, ## Граничные случаи, ## Влияние на связанные компоненты,
|
||||||
|
## Вне scope, ## Критерии приемки).
|
||||||
|
- Контекст: проблема из audit (<repo>) — <name>: <detail>. Путь: <path>.
|
||||||
|
Источник: <source>. Severity: <severity>.
|
||||||
|
- Задача: что починить (конкретно, с путями к файлам).
|
||||||
|
- Контракты: конкретные изменения (что должно стать после фикса).
|
||||||
|
- Инварианты: что НЕ ломать при фиксе.
|
||||||
|
- Граничные случаи: edge cases при фиксе.
|
||||||
|
- Влияние на связанные компоненты: зависящие audit-категории/оракулы/промпты; paired updates; «нет связанных компонентов» для тривиальных фиксов.
|
||||||
|
- Вне scope: что НЕ делаем в этом issue.
|
||||||
|
- Критерии приемки: чек-лист (включая `project-status` проходит эту
|
||||||
|
категорию после фикса).
|
||||||
|
3. `create-issue({ title: "fix(<scope>): <description>", body, labels: ["tech-debt", "from-audit"] })`
|
||||||
|
tool (НЕ raw `gh issue create` — заблокирован deny). tool валидирует
|
||||||
|
conventional title + 8 headings + Cyrillic.
|
||||||
|
4. Верни: issue URL (или ошибку валидации для оркестратора).
|
||||||
|
|
||||||
|
Если gh недоступен → верни: "gh unavailable: <reason>". НЕ retry, НЕ fallback.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Main agent = оркестратор: `project-status` tool + `explore` subagent +
|
||||||
|
вопрос юзеру + `task(general)` для create-issue. Не делает
|
||||||
|
edit/read/`gh issue create` сам.
|
||||||
|
- `project-status` — read-only oracle, ALLOWED для оркестратора.
|
||||||
|
- Audit — read-only (НЕ редактирует код, только создаёт issues).
|
||||||
|
- WARN из `project-status` → issues, НЕ FIX напрямую.
|
||||||
|
- 1 проблема = 1 issue (для отдельного `/run-pipeline`).
|
||||||
|
- Issue body self-contained (8 headings, `create-issue` валидация).
|
||||||
|
- `code-standards` — через `skill()` tool, НЕ хардкод.
|
||||||
|
- Audit — линейный flow (не `audit-status` tool).
|
||||||
|
- create-issue subagents — последовательно (Linear Execution).
|
||||||
|
- Subagent error → 1 retry, потом STOP + report.
|
||||||
|
|
@ -10,6 +10,6 @@ If a bug is found during work that is outside the scope of the current task:
|
||||||
1. Check `gh issue list` for duplicates.
|
1. Check `gh issue list` for duplicates.
|
||||||
2. Create a GitHub issue via `create-issue` tool (NOT raw `gh issue create`).
|
2. Create a GitHub issue via `create-issue` tool (NOT raw `gh issue create`).
|
||||||
3. Title: `fix(scope): short description` in English.
|
3. Title: `fix(scope): short description` in English.
|
||||||
4. Body: `## Контекст` / `## Задача` / `## Контракты` / `## Инварианты` / `## Граничные случаи` / `## Вне scope` / `## Критерии приемки` (in Russian).
|
4. Body: `## Контекст` / `## Задача` / `## Контракты` / `## Инварианты` / `## Граничные случаи` / `## Влияние на связанные компоненты` / `## Вне scope` / `## Критерии приемки` (in Russian).
|
||||||
5. Continue the current task. Do NOT fix the bug yourself.
|
5. Continue the current task. Do NOT fix the bug yourself.
|
||||||
6. Report to orchestrator: "Created issue #N: ...".
|
6. Report to orchestrator: "Created issue #N: ...".
|
||||||
|
|
@ -25,3 +25,113 @@ description: Universal code standards for any language. Use when writing, refact
|
||||||
- AGENTS.md правило «No comments unless requested» — это **default**: код без комментариев
|
- AGENTS.md правило «No comments unless requested» — это **default**: код без комментариев
|
||||||
- Этот skill описывает **исключение**: Google-style docstrings на английском для публичных API — когда контракт warrants (библиотечный API, public surface)
|
- Этот skill описывает **исключение**: Google-style docstrings на английском для публичных API — когда контракт warrants (библиотечный API, public surface)
|
||||||
- Описывай **зачем**, а не **что** — код и так говорит что делает
|
- Описывай **зачем**, а не **что** — код и так говорит что делает
|
||||||
|
|
||||||
|
## 5. Architecture: good vs bad
|
||||||
|
|
||||||
|
Слои для **backend**: `routes → schemas → services → db/models` (4-tier, однонаправленный). Роуты тонкие (импортируют только `services` + `schemas`), сервисы работают с `db/models`, бизнес-логика здесь. `project-status.py` enforces subset (thin routes, centralized models); этот раздел объясняет «почему».
|
||||||
|
|
||||||
|
### Backend (подробно)
|
||||||
|
|
||||||
|
**GOOD tree (синтетический):**
|
||||||
|
```
|
||||||
|
src/<package>/
|
||||||
|
├── api/
|
||||||
|
│ ├── v1/
|
||||||
|
│ │ ├── routes/users.py ← тонкие роуты, импортируют только services + schemas
|
||||||
|
│ │ ├── dependencies.py ← Depends(), get_current_user
|
||||||
|
│ │ └── router.py
|
||||||
|
│ └── router.py
|
||||||
|
├── config/
|
||||||
|
│ ├── settings.py ← pydantic-settings
|
||||||
|
│ └── logger.py ← loguru setup
|
||||||
|
├── db/
|
||||||
|
│ ├── connection.py ← Tortoise.init
|
||||||
|
│ └── models/ ← ВСЕ ORM-модели здесь (centralized)
|
||||||
|
│ ├── user.py
|
||||||
|
│ ├── post.py
|
||||||
|
│ └── comment.py
|
||||||
|
├── schemas/ ← Pydantic DTO (НЕ Tortoise models)
|
||||||
|
│ ├── base.py
|
||||||
|
│ ├── user.py
|
||||||
|
│ └── post.py
|
||||||
|
├── services/ ← бизнес-логика (работает с db/models)
|
||||||
|
│ ├── user_service.py
|
||||||
|
│ └── post_service.py
|
||||||
|
└── utils/
|
||||||
|
└── metadata.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Правила GOOD:**
|
||||||
|
1. Все ORM-модели в `db/models/` (centralized)
|
||||||
|
2. Schemas (Pydantic) отдельно от models (Tortoise) — НЕ смешивать
|
||||||
|
3. Роуты тонкие — импортируют только `services` и `schemas`
|
||||||
|
4. Сервисы работают с `db/models` — бизнес-логика здесь
|
||||||
|
5. Слои: routes → schemas → services → db/models (4-tier, однонаправленный)
|
||||||
|
|
||||||
|
**BAD tree 1 — feature-scatter:**
|
||||||
|
```
|
||||||
|
src/<package>/
|
||||||
|
├── channels/
|
||||||
|
│ ├── models.py ← ❌ модель здесь (scatter)
|
||||||
|
│ ├── routes.py
|
||||||
|
│ └── service.py
|
||||||
|
├── monitor/
|
||||||
|
│ ├── models.py ← ❌ ещё модель здесь
|
||||||
|
│ └── routes.py
|
||||||
|
├── logs/
|
||||||
|
│ └── models.py ← ❌ и здесь
|
||||||
|
├── models.py ← ❌ root-level модель
|
||||||
|
├── db.py ← ❌ connection flat (не db/connection.py)
|
||||||
|
└── main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Проблемы BAD 1: модели раскиданы по feature-папкам; `models.py` в root; `db.py` flat; не publishable; Tortoise `modules` должен перечислять 4+ файла вручную.
|
||||||
|
|
||||||
|
**BAD tree 2 — mixed-layers:**
|
||||||
|
```
|
||||||
|
src/<package>/
|
||||||
|
├── api/
|
||||||
|
│ ├── v1/
|
||||||
|
│ │ └── users.py ← ❌ роут содержит бизнес-логику + Tortoise queries
|
||||||
|
│ └── models.py ← ❌ модели в api/ (не в db/models/)
|
||||||
|
├── services/
|
||||||
|
│ └── user_service.py
|
||||||
|
│ └── schemas.py ← ❌ schemas в services/ (не в schemas/)
|
||||||
|
└── main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Проблемы BAD 2: роут делает Tortoise queries напрямую (не тонкий); модели в `api/models.py` (не `db/models/`); schemas внутри services (не отдельный слой).
|
||||||
|
|
||||||
|
### Fullstack (кратко)
|
||||||
|
|
||||||
|
Backend as above (in `backend/` + `frontend/` separation). Frontend: SvelteKit co-located `*.test.ts` в `src/lib/`, `e2e/*.spec.ts` для Playwright. НЕ смешивать backend код в `frontend/` и наоборот. + mobile-first (PWA + Playwright mobile + axe a11y) — silent enforcement через `STACK_REQUIRED["fullstack"]`.
|
||||||
|
|
||||||
|
### CLI (кратко)
|
||||||
|
|
||||||
|
`cli.py` (Typer commands) + `core.py` (business logic). Нет api/db/schemas layers. `tests/test_cli.py` + `tests/test_core.py`.
|
||||||
|
|
||||||
|
## 6. Tests
|
||||||
|
|
||||||
|
Что писать (как запускать — в `run-tests` skill). `project-status.py` enforces subset (conftest required, anti-stub, mirror structure); этот раздел объясняет «почему».
|
||||||
|
|
||||||
|
### Типы тестов
|
||||||
|
|
||||||
|
- **Regression** — воспроизводит конкретный баг, который был исправлен. Ссылается на issue/PR (`test_parser_handles_crlf_regression_#227`).
|
||||||
|
- **Integration** — пересекает слои (DB+API, scheduler+DB). Имеет `pytest.mark.integration` + `skipif` opt-in. В `tests/integration/`.
|
||||||
|
- **Unit** — чистая функция/сервис, без DB/сети. В `tests/unit/`.
|
||||||
|
|
||||||
|
### Антипаттерны
|
||||||
|
|
||||||
|
- **Stub files** — `test_*.py` без `def test_*`/`async def test_*` (digital_factory 50/62 файлов). `project-status` WARNs.
|
||||||
|
- **Тесты без assertions** — только `print`/`logger.info`. Каждый тест должен иметь минимум 1 `assert`.
|
||||||
|
- **Тесты ради тестов** — coverage ради coverage, без реальной проверки поведения.
|
||||||
|
|
||||||
|
### Mirror structure (backend)
|
||||||
|
|
||||||
|
- `tests/unit/` ↔ `src/<pkg>/services/` (unit-тесты сервисов)
|
||||||
|
- `tests/api/` ↔ `src/<pkg>/api/v1/routes/` (route-тесты через TestClient)
|
||||||
|
- `tests/integration/` ↔ cross-cutting flows (opt-in)
|
||||||
|
|
||||||
|
### conftest.py
|
||||||
|
|
||||||
|
Обязателен (backend). Shared fixtures: `mock_settings`, `client`, `auth_client`, `create_<entity>` factories.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,30 @@ description: Use when adding, changing, or removing MCP servers, providers, perm
|
||||||
- **Исключение:** явный override-сценарий (project-local конфиг нужен для изоляции в конкретном проекте) — тогда указать явно в комментарии к изменению.
|
- **Исключение:** явный override-сценарий (project-local конфиг нужен для изоляции в конкретном проекте) — тогда указать явно в комментарии к изменению.
|
||||||
- **Docker (опционально):** для контейнерного запуска можно bind-mount `.opencode/` → `~/.config/opencode/` (см. `docker-compose.yml`) — но это optional path, не канон. Репо обязан работать и bare-`opencode` в клона.
|
- **Docker (опционально):** для контейнерного запуска можно bind-mount `.opencode/` → `~/.config/opencode/` (см. `docker-compose.yml`) — но это optional path, не канон. Репо обязан работать и bare-`opencode` в клона.
|
||||||
|
|
||||||
|
## 1.1. Scope правила «куда писать» (не только `opencode.json`)
|
||||||
|
|
||||||
|
Каноническое правило «всегда workspace clone `slaid098/opencode-config`, никогда `~/.config/opencode/`» распространяется **на все артефакты конфига opencode**, а не только на `opencode.json`:
|
||||||
|
|
||||||
|
- `.opencode/opencode.json` — конфиг (MCP, providers, permissions, …).
|
||||||
|
- `AGENTS.md` (корень репо) — глобальные правила/инструкции.
|
||||||
|
- `.opencode/skills/<name>/SKILL.md` — скиллы.
|
||||||
|
- `.opencode/agents/<name>.md` — сабагенты.
|
||||||
|
- `.opencode/commands/<name>.md` — слэш-команды.
|
||||||
|
|
||||||
|
**Архитектура bind-mount (Docker-сетап):**
|
||||||
|
|
||||||
|
- `~/.config/opencode/` внутри контейнера — это bind-mount из хост-сорса `/root/dockers/opencode-config/.opencode/` (НЕ `config/`).
|
||||||
|
- Глобальный `AGENTS.md` (`~/.config/opencode/AGENTS.md`) — отдельный ro-mount из корня репо.
|
||||||
|
- Любая правка файлов в `~/.config/opencode/` напрямую физически меняет файлы на хост-сорсе **в обход git**. Другой агент коммитит из workspace clone и пушит → на хосте `git pull` → конфликт (host-source уже имеет uncommitted изменения).
|
||||||
|
|
||||||
|
**Правильно (sync процедура):**
|
||||||
|
|
||||||
|
1. Правка в workspace clone `/root/workspace/opencode-config/` → `commit` + `git push`.
|
||||||
|
2. На хосте: `git pull` (обновляет `/root/dockers/opencode-config/.opencode/`).
|
||||||
|
3. `docker compose restart opencode` (MCP/skills/agents грузятся при старте контейнера — до рестарта правки не видны).
|
||||||
|
|
||||||
|
**Запрещено:** редактировать `~/.config/opencode/` внутри контейнера напрямую (это bind-mount, ro `AGENTS.md`, bypass git → конфликты при `git pull` на хосте).
|
||||||
|
|
||||||
## 2. Применение изменений
|
## 2. Применение изменений
|
||||||
|
|
||||||
- `commit` + `push` в репо `slaid098/opencode-config` (через `commit` tool).
|
- `commit` + `push` в репо `slaid098/opencode-config` (через `commit` tool).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: feature-spec
|
name: feature-spec
|
||||||
description: Lightweight SDD-style Q&A skill for feature planning. Guides the agent through Spec-Driven Development questions before implementation. Produces a structured plan with 7 SDD sections (Контекст, Задача, Контракты, Инварианты, Граничные случаи, Вне scope, Критерии приемки). Also when user says "спека фичи", "feature spec", "план фичи", "обсудим фичу", "spec feature", "спецификация фичи".
|
description: Lightweight SDD-style Q&A skill for feature planning. Guides the agent through Spec-Driven Development questions before implementation. Runs an explore subagent to find related/linked components (oracle scripts, validators, agents, prompts) before Q&A, then produces a structured plan with 8 SDD sections (Контекст, Задача, Контракты, Инварианты, Граничные случаи, Влияние на связанные компоненты, Вне scope, Критерии приемки). Also when user says "спека фичи", "feature spec", "план фичи", "обсудим фичу", "spec feature", "спецификация фичи".
|
||||||
---
|
---
|
||||||
|
|
||||||
# Feature Spec
|
# Feature Spec
|
||||||
|
|
@ -21,6 +21,21 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
||||||
|
|
||||||
Юзер описывает фичу. Агент читает SDD-шаблон (ниже) и определяет, каких данных не хватает.
|
Юзер описывает фичу. Агент читает SDD-шаблон (ниже) и определяет, каких данных не хватает.
|
||||||
|
|
||||||
|
### 1.5. Поиск связанных компонентов
|
||||||
|
|
||||||
|
ДО Q&A — автоматический search по репо, чтобы найти связанные компоненты и дать вводные для 8-й SDD-секции.
|
||||||
|
|
||||||
|
- Запустить `explore` subagent с `rg` по репо.
|
||||||
|
- Найти: кто ссылается на изменяемый файл/функцию/формат/литерал/frontmatter key.
|
||||||
|
- Категории для поиска:
|
||||||
|
- oracle-скрипты (`pipeline-status`, `spec-status`, `project-status`)
|
||||||
|
- валидаторы (`create-issue`, `create-readme`)
|
||||||
|
- агенты (`memory-syncer`, `reviewer`)
|
||||||
|
- промпты (skills)
|
||||||
|
- Для каждого найденного компонента — отметить: как изменение повлияет, нужен ли paired update.
|
||||||
|
- Результат — вводные для 8-й секции SDD (`## Влияние на связанные компоненты`).
|
||||||
|
- Если связанных компонентов нет — явно отметить (explore search не нашёл).
|
||||||
|
|
||||||
### 2. Q&A
|
### 2. Q&A
|
||||||
|
|
||||||
Агент задаёт вопросы списком — НЕ гадает. Пример:
|
Агент задаёт вопросы списком — НЕ гадает. Пример:
|
||||||
|
|
@ -30,10 +45,11 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
||||||
1. Контракты: какой формат запроса/ответа? Какие коды ошибок?
|
1. Контракты: какой формат запроса/ответа? Какие коды ошибок?
|
||||||
2. Инварианты: какие лимиты? Какой TTL? Какая модель/библиотека?
|
2. Инварианты: какие лимиты? Какой TTL? Какая модель/библиотека?
|
||||||
3. Граничные случаи: что если внешний сервис недоступен? Что если данных нет?
|
3. Граничные случаи: что если внешний сервис недоступен? Что если данных нет?
|
||||||
4. Вне scope: что точно НЕ делаем в этой итерации?
|
4. Влияние на связанные компоненты: какие детерминированные связи (oracle-скрипты, валидаторы, парсеры, промпты-агенты) зависят от этого изменения? Что может сломаться если поменять X? Нужны ли paired updates в других файлах? (explore subagent уже нашёл candidates на шаге 1.5 — юзер подтверждает/дополняет)
|
||||||
|
5. Вне scope: что точно НЕ делаем в этой итерации?
|
||||||
```
|
```
|
||||||
|
|
||||||
Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 7 секций не заполнены.
|
Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 8 секций не заполнены.
|
||||||
|
|
||||||
### 3. План
|
### 3. План
|
||||||
|
|
||||||
|
|
@ -56,6 +72,9 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
||||||
## Граничные случаи
|
## Граничные случаи
|
||||||
[Что при ошибках: невалидный вход, отказ сервиса, превышение лимита]
|
[Что при ошибках: невалидный вход, отказ сервиса, превышение лимита]
|
||||||
|
|
||||||
|
## Влияние на связанные компоненты
|
||||||
|
[Файлы/оракулы/агенты/промпты/валидаторы, которые зависят от изменения; нужен ли paired update. Если нет — явно «нет связанных компонентов»]
|
||||||
|
|
||||||
## Вне scope
|
## Вне scope
|
||||||
[Что НЕ делаем]
|
[Что НЕ делаем]
|
||||||
|
|
||||||
|
|
@ -68,7 +87,7 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
||||||
|
|
||||||
После готовности плана:
|
После готовности плана:
|
||||||
- Агент: "План готов. Скажи 'создай issue' чтобы создать issue, потом запусти /run-pipeline."
|
- Агент: "План готов. Скажи 'создай issue' чтобы создать issue, потом запусти /run-pipeline."
|
||||||
- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (7 секций) → issue создан
|
- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (8 секций) → issue создан
|
||||||
- Юзер: `/run-pipeline` → реализация
|
- Юзер: `/run-pipeline` → реализация
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
@ -83,9 +102,9 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
||||||
- **Не создавай файлы** — план живёт в чате, потом в issue
|
- **Не создавай файлы** — план живёт в чате, потом в issue
|
||||||
- **1 issue = 1 PR** — если фича большая, предложи разбить на подзадачи
|
- **1 issue = 1 PR** — если фича большая, предложи разбить на подзадачи
|
||||||
|
|
||||||
## SDD-шаблон (7 секций)
|
## SDD-шаблон (8 секций)
|
||||||
|
|
||||||
Совпадает с `create-issue` validation (PR #169):
|
Совпадает с `create-issue` validation (PR #169, расширено в #249):
|
||||||
|
|
||||||
| # | Секция | Что содержит |
|
| # | Секция | Что содержит |
|
||||||
|---|--------|-------------|
|
|---|--------|-------------|
|
||||||
|
|
@ -94,5 +113,29 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
||||||
| 3 | `## Контракты` | Ожидаемое поведение: API, форматы, коды ошибок |
|
| 3 | `## Контракты` | Ожидаемое поведение: API, форматы, коды ошибок |
|
||||||
| 4 | `## Инварианты` | Правила без исключений: лимиты, ограничения, технологии |
|
| 4 | `## Инварианты` | Правила без исключений: лимиты, ограничения, технологии |
|
||||||
| 5 | `## Граничные случаи` | Что при ошибках: edge cases, отказы сервисов |
|
| 5 | `## Граничные случаи` | Что при ошибках: edge cases, отказы сервисов |
|
||||||
| 6 | `## Вне scope` | Что НЕ делаем в этой итерации |
|
| 6 | `## Влияние на связанные компоненты` | Файлы/оракулы/агенты/промпты/валидаторы, зависящие от изменения; paired updates; «нет связанных компонентов» для тривиальных фич |
|
||||||
| 7 | `## Критерии приемки` | Проверяемые сценарии: "X → видит Y" |
|
| 7 | `## Вне scope` | Что НЕ делаем в этой итерации |
|
||||||
|
| 8 | `## Критерии приемки` | Проверяемые сценарии: "X → видит Y" |
|
||||||
|
|
||||||
|
## Пример: кейс #238 (memory-syncer ↔ pipeline-status)
|
||||||
|
|
||||||
|
Реальный кейс, который мотивировал 8-ю секцию. PR #239 изменил `memory-syncer.md` (файл-ротация: пишет в `{repo}-002.md` при заморозке). `pipeline-status.py::check_memory()` читал только `{repo}.md` → не нашёл receipt → pipeline завис. Связь writer↔reader детерминированная, но feature-spec её не увидел.
|
||||||
|
|
||||||
|
Шаг 1.5 (explore subagent) нашёл бы:
|
||||||
|
- `pipeline-status.py::check_memory()` / `get_memory_file_path()` — читает `{repo}.md` для поиска `PR#N` receipt. Если memory-syncer пишет в `{repo}-002.md` → оракул не найдёт receipt → MEMORY фаза зависает. Нужен paired update: `get_memory_file_path()` должен сканировать `{repo}*.md` glob.
|
||||||
|
- `memory` skill — ссылается на формат memory-файлов, обновить примеры ротации.
|
||||||
|
|
||||||
|
8-я секция SDD для такой фичи:
|
||||||
|
```
|
||||||
|
## Влияние на связанные компоненты
|
||||||
|
|
||||||
|
- `pipeline-status.py:check_memory()` / `get_memory_file_path()` — читает `{repo}.md` для поиска `PR#N` receipt. Если memory-syncer пишет в `{repo}-002.md` → оракул не найдёт receipt → MEMORY фаза зависает. Нужен paired update: `get_memory_file_path()` должен сканировать `{repo}*.md` glob.
|
||||||
|
- `memory` skill — ссылается на формат memory-файлов, обновить примеры ротации.
|
||||||
|
```
|
||||||
|
|
||||||
|
Для тривиальной фичи (cosmetic README update):
|
||||||
|
```
|
||||||
|
## Влияние на связанные компоненты
|
||||||
|
|
||||||
|
Нет связанных компонентов (cosmetic README update).
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: get-project-map
|
name: get-project-map
|
||||||
description: Use when you need to view or update the current project folder/file structure (especially after creating/deleting files or switching branches), or understand package layout in the workspace. Also contains a template for maintaining docs/project-map/. Also when user says "структура проекта", "project map", "дерево файлов".
|
description: Use when you need to view or update the current project folder/file structure (especially after creating/deleting files or switching branches), or understand package layout in the workspace. Also when user says "структура проекта", "project map", "дерево файлов".
|
||||||
---
|
---
|
||||||
|
|
||||||
# Навык получения карты проекта (Project Map)
|
# Навык получения карты проекта (Project Map)
|
||||||
|
|
@ -17,113 +17,3 @@ description: Use when you need to view or update the current project folder/file
|
||||||
1. Запусти указанную команду в терминале. Она выведет дерево каталогов и список файлов с их размерами прямо в stdout.
|
1. Запусти указанную команду в терминале. Она выведет дерево каталогов и список файлов с их размерами прямо в stdout.
|
||||||
2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов.
|
2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов.
|
||||||
3. Не сохраняй вывод в файлы на диск — читай его напрямую из вывода терминала.
|
3. Не сохраняй вывод в файлы на диск — читай его напрямую из вывода терминала.
|
||||||
|
|
||||||
## Персистентная карта проекта (docs/project-map/)
|
|
||||||
|
|
||||||
Помимо живого дерева через `repomix`, в репозитории может быть персистентная карта в `docs/project-map/`. Эта карта обновляется docs-reviewer агентом перед каждым code review.
|
|
||||||
|
|
||||||
### Структура
|
|
||||||
- `docs/project-map/README.md` — индекс, общая структура, список модулей
|
|
||||||
- `docs/project-map/<module>.md` — один файл на модуль/директорию верхнего уровня
|
|
||||||
|
|
||||||
### Шаблон MD-файла модуля
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
module: <путь к модулю>
|
|
||||||
purpose: <назначение в одну строку>
|
|
||||||
key_files:
|
|
||||||
- <путь> — <роль>
|
|
||||||
- <путь> — <роль>
|
|
||||||
dependencies: [<зависимости>]
|
|
||||||
last_updated: <YYYY-MM-DD>
|
|
||||||
---
|
|
||||||
|
|
||||||
# <имя модуля>
|
|
||||||
|
|
||||||
## Структура
|
|
||||||
- `<файл>` — <описание>
|
|
||||||
- `<файл>` — <описание>
|
|
||||||
|
|
||||||
## Паттерны
|
|
||||||
- <используемые паттерны/конвенции>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Что включать
|
|
||||||
- Структуру директорий (дерево модуля)
|
|
||||||
- Назначение модуля/директории
|
|
||||||
- Ключевые файлы и их роли
|
|
||||||
- Зависимости между модулями
|
|
||||||
|
|
||||||
### Что НЕ включать
|
|
||||||
- Implementation details
|
|
||||||
- API signatures
|
|
||||||
- Внутреннюю логику
|
|
||||||
|
|
||||||
### Когда обновлять
|
|
||||||
- Добавлены новые файлы или директории
|
|
||||||
- Удалены файлы или директории
|
|
||||||
- Переименованы файлы или директории
|
|
||||||
- Новые модули верхнего уровня
|
|
||||||
|
|
||||||
## Handoff файлы (docs/handoff/)
|
|
||||||
|
|
||||||
Контекст передаётся между сессиями через handoff-файлы — один файл на PR.
|
|
||||||
|
|
||||||
### Структура
|
|
||||||
- `docs/handoff/pr-<N>-<slug>.md` — handoff для PR #N
|
|
||||||
|
|
||||||
### Шаблон
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
pr: <N>
|
|
||||||
title: <PR title>
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
<2-3 строки>
|
|
||||||
|
|
||||||
## Почему
|
|
||||||
<1-2 строки>
|
|
||||||
|
|
||||||
## Pending
|
|
||||||
<что осталось, или "—">
|
|
||||||
|
|
||||||
## Watch out
|
|
||||||
<gotchas, или "—">
|
|
||||||
```
|
|
||||||
|
|
||||||
## ADR файлы (docs/decisions/)
|
|
||||||
|
|
||||||
Архитектурные решения сохраняются в ADR (Architecture Decision Records).
|
|
||||||
|
|
||||||
### Структура
|
|
||||||
- `docs/decisions/<NN>-pr-<N>-<slug>.md` — один файл на решение
|
|
||||||
- Numbering: `001`, `002`, `003`, ... (zero-padded, sequential)
|
|
||||||
|
|
||||||
### Шаблон
|
|
||||||
```markdown
|
|
||||||
# ADR-<NN>: <title>
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
Accepted (<YYYY-MM-DD>)
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
<почему нужно было решение>
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
<что решили>
|
|
||||||
|
|
||||||
## Альтернативы
|
|
||||||
- <вариант>: <почему не подошёл>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Когда создавать ADR
|
|
||||||
- Новый паттерн или конвенция
|
|
||||||
- Архитектурное изменение (новый модуль, изменённые зависимости)
|
|
||||||
- Неочевидное решение (почему X, а не Y)
|
|
||||||
|
|
||||||
### Когда НЕ создавать ADR
|
|
||||||
- Bug fixes
|
|
||||||
- Refactoring without architectural change
|
|
||||||
- Documentation updates
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ Issue должно содержать всё необходимое, чтобы
|
||||||
## Граничные случаи
|
## Граничные случаи
|
||||||
[Что при ошибках: невалидный вход, отказ внешнего сервиса, превышение лимита]
|
[Что при ошибках: невалидный вход, отказ внешнего сервиса, превышение лимита]
|
||||||
|
|
||||||
|
## Влияние на связанные компоненты
|
||||||
|
[Связанные файлы/оракулы/агенты/промпты/валидаторы; paired updates; «нет связанных компонентов» для тривиальных задач]
|
||||||
|
|
||||||
## Вне scope
|
## Вне scope
|
||||||
[Что НЕ делаем в этой итерации]
|
[Что НЕ делаем в этой итерации]
|
||||||
|
|
||||||
|
|
@ -67,8 +70,8 @@ Issue создаёт **subagent** (general type), а не основной аг
|
||||||
**Subagent (полная ответственность):**
|
**Subagent (полная ответственность):**
|
||||||
1. Загрузи навык `issue`
|
1. Загрузи навык `issue`
|
||||||
2. Собери контекст — прочитай файлы из intent summary, пойми задачу, оцени объём (правило дробления ниже)
|
2. Собери контекст — прочитай файлы из intent summary, пойми задачу, оцени объём (правило дробления ниже)
|
||||||
3. Составь self-contained body по шаблону (Контекст → Задача → Контракты → Инварианты → Граничные случаи → Вне scope → Критерии приемки)
|
3. Составь self-contained body по шаблону (Контекст → Задача → Контракты → Инварианты → Граничные случаи → Влияние на связанные компоненты → Вне scope → Критерии приемки)
|
||||||
4. Запусти `create-issue({ title: "...", body: "...", labels: ["..."] })` tool (НЕ raw `gh issue create` — заблокирован deny; tool валидирует conventional title format и headings `## Контекст`/`## Задача`/`## Контракты`/`## Инварианты`/`## Граничные случаи`/`## Вне scope`/`## Критерии приемки`)
|
4. Запусти `create-issue({ title: "...", body: "...", labels: ["..."] })` tool (НЕ raw `gh issue create` — заблокирован deny; tool валидирует conventional title format и headings `## Контекст`/`## Задача`/`## Контракты`/`## Инварианты`/`## Граничные случаи`/`## Влияние на связанные компоненты`/`## Вне scope`/`## Критерии приемки`)
|
||||||
5. Верни URL созданного issue основному агенту
|
5. Верни URL созданного issue основному агенту
|
||||||
|
|
||||||
Main agent НЕ пишет body и НЕ запускает `create-issue` — всё через subagent. Это согласовано с `run-pipeline` skill (Phase 0: "через subagent с `issue` skill") и `AGENTS.md` (Dev Workflow, step 2: "delegate to `task` subagent").
|
Main agent НЕ пишет body и НЕ запускает `create-issue` — всё через subagent. Это согласовано с `run-pipeline` skill (Phase 0: "через subagent с `issue` skill") и `AGENTS.md` (Dev Workflow, step 2: "delegate to `task` subagent").
|
||||||
|
|
@ -101,6 +104,10 @@ Main agent НЕ пишет body и НЕ запускает `create-issue` — в
|
||||||
- Кеш содержит устаревший формат → invalidate, пересчитать
|
- Кеш содержит устаревший формат → invalidate, пересчитать
|
||||||
- Конкурентные запросы на одно видео → первый пишет в кеш, последующие берут из кеша
|
- Конкурентные запросы на одно видео → первый пишет в кеш, последующие берут из кеша
|
||||||
|
|
||||||
|
## Влияние на связанные компоненты
|
||||||
|
- AnalysisController зависит от AnalysisService — без изменений (API сохранён)
|
||||||
|
- «Нет связанных компонентов» для тривиальных задач
|
||||||
|
|
||||||
## Вне scope
|
## Вне scope
|
||||||
- ❌ Кеширование субтитров (отдельная задача)
|
- ❌ Кеширование субтитров (отдельная задача)
|
||||||
- ❌ Инвалидация по времени просмотра видео
|
- ❌ Инвалидация по времени просмотра видео
|
||||||
|
|
@ -132,9 +139,10 @@ create-issue({ title: "type(scope): description", body: "...", labels: ["<label>
|
||||||
|
|
||||||
Tool валидирует: title соответствует conventional format (type(scope): desc,
|
Tool валидирует: title соответствует conventional format (type(scope): desc,
|
||||||
≤80 chars, English), body содержит `## Контекст`, `## Задача`, `## Контракты`,
|
≤80 chars, English), body содержит `## Контекст`, `## Задача`, `## Контракты`,
|
||||||
`## Инварианты`, `## Граничные случаи`, `## Вне scope`, `## Критерии приемки`
|
`## Инварианты`, `## Граничные случаи`, `## Влияние на связанные компоненты`,
|
||||||
headings и на русском (Cyrillic обязательна). При ошибке валидации
|
`## Вне scope`, `## Критерии приемки` headings и на русском (Cyrillic
|
||||||
tool возвращает ошибку и НЕ вызывает gh — почини формат и повтори.
|
обязательна). При ошибке валидации tool возвращает ошибку и НЕ вызывает gh —
|
||||||
|
почини формат и повтори.
|
||||||
|
|
||||||
Label выбирай по типу задачи (совпадает с commit `type`):
|
Label выбирай по типу задачи (совпадает с commit `type`):
|
||||||
- `enhancement` — новая функциональность (`feat`)
|
- `enhancement` — новая функциональность (`feat`)
|
||||||
|
|
@ -157,11 +165,10 @@ Label выбирай по типу задачи (совпадает с commit `t
|
||||||
После создания issue, цикл продолжается (см. `run-pipeline` skill для деталей PR процесса):
|
После создания issue, цикл продолжается (см. `run-pipeline` skill для деталей PR процесса):
|
||||||
|
|
||||||
1. **Subagent** — `task(general)` читает issue, реализует, коммитит, push, создаёт PR. Оркестрация — через `run-pipeline` skill.
|
1. **Subagent** — `task(general)` читает issue, реализует, коммитит, push, создаёт PR. Оркестрация — через `run-pipeline` skill.
|
||||||
2. **Docs review** — `@docs-reviewer` subagent валидирует handoff + ADR, обновляет project map (pre-merge).
|
2. **Code review** — `@reviewer` subagent ревьюит PR (diff, skills, standards), постит `## Code Review Summary` комментарий.
|
||||||
3. **Code review** — `@reviewer` subagent ревьюит PR (diff, skills, standards), постит `## Code Review Summary` комментарий.
|
3. **Merge or Repeat** — APPROVE → `merge-pr({ pr_number: N })` tool (squash +
|
||||||
4. **Merge or Repeat** — APPROVE → `merge-pr({ pr_number: N })` tool (squash +
|
|
||||||
delete branch, без `--admin`; НЕ raw `gh pr merge` — заблокирован deny),
|
delete branch, без `--admin`; НЕ raw `gh pr merge` — заблокирован deny),
|
||||||
после CI ✅; замечания → fix subagent → re-review → merge.
|
после CI ✅; замечания → fix subagent → re-review → merge.
|
||||||
5. **Memory-sync** — `@memory-syncer` дистиллирует handoff + ADR в `<memory_dir>/repos/{host}/{org}/{repo}.md`.
|
4. **Memory-sync** — `@memory-syncer` дистиллирует PR body в `<memory_dir>/repos/{host}/{org}/{repo}.md`.
|
||||||
|
|
||||||
См. `AGENTS.md` (Development Workflow) и `run-pipeline` skill — все три документа описывают одну и ту же full-subagent модель делегирования.
|
См. `AGENTS.md` (Development Workflow) и `run-pipeline` skill — все три документа описывают одну и ту же full-subagent модель делегирования.
|
||||||
|
|
@ -106,9 +106,16 @@ Default: `/root/.local/share/opencode/opencode-memory` (переопределя
|
||||||
|
|
||||||
Перед добавлением записи — прочитай существующий файл. Если факт уже записан — обнови запись (bump `updated` в frontmatter, дополни детали если нужно). **НЕ создавай дубликаты.** Дубликаты раздули файлы до 600+ KB. Каждая гоча/паттерн/root cause = одна запись, не по одной на каждый PR где упоминалась.
|
Перед добавлением записи — прочитай существующий файл. Если факт уже записан — обнови запись (bump `updated` в frontmatter, дополни детали если нужно). **НЕ создавай дубликаты.** Дубликаты раздули файлы до 600+ KB. Каждая гоча/паттерн/root cause = одна запись, не по одной на каждый PR где упоминалась.
|
||||||
|
|
||||||
### Лимит 100 KB
|
### Ротация файлов (50 KB)
|
||||||
|
|
||||||
Если после записи файл > 100 KB → компрессировать: прочитай все старые записи, оставь только durable (gotchas, паттерны, root causes, ADR-указатели), выкинь не-durable (changelog-дампы, статусы, хроника событий, receipts с повторяющимся содержанием, метрики PR). Tags-строка < 500 символов, summary < 500 символов. Цель — держать файл < 100 KB.
|
Память репо ротируется по размеру вместо inline compaction:
|
||||||
|
- один репо → один или несколько файлов по пути `{memory-dir}/repos/{host}/{org}/{repo}*.md`
|
||||||
|
- первый файл: `{repo}.md`; последующие (когда первый заморожен): `{repo}-002.md`, `{repo}-003.md`, ... (3-значный sequential, не по дате)
|
||||||
|
- порог ротации: **50 KB** (soft). Файл с размером ≥ 50 KB считается замороженным — новые записи в него НЕ пишутся, открывается следующий файл
|
||||||
|
- замороженные файлы остаются редактируемыми для dedup (обновление существующих гоч, bump `updated` в их frontmatter); новые записи в замороженный файл — НЕ пишутся
|
||||||
|
- `memory-syncer` выбирает активный файл (первый существующий с размером < 50 KB) перед каждой записью и ищет дубликаты по всем `repo*.md` (включая замороженные) — см. `memory-syncer` agent
|
||||||
|
|
||||||
|
Inline compaction удалён: память = durable выжимка (сжимать некуда), git = бесконечный backup (без отдельных archive-файлов). Receipts остаются в файлах (audit trail). Существующие большие файлы (`youtube-soft.md` 112 KB, `opencode.md` 80 KB, `opencode-config.md` 64 KB) миграции не требуют — при следующей записи откроется `-002.md`.
|
||||||
|
|
||||||
### Квитанция ставится всегда
|
### Квитанция ставится всегда
|
||||||
|
|
||||||
|
|
|
||||||
352
.opencode/skills/project-template/SKILL.md
Normal file
352
.opencode/skills/project-template/SKILL.md
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
---
|
||||||
|
name: project-template
|
||||||
|
description: Init new project from cookiecutter template by type + GitHub remote + branch protection, or audit existing project via project-status. Replaces repo-init. Also when user says "новый проект", "инициализируй репо", "проверь проект", "create project", "project template".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Template
|
||||||
|
|
||||||
|
Два flow: **init** (новый проект: cookiecutter по типу + GitHub remote) и
|
||||||
|
**check** (существующий проект: project-status аудит с рекомендациями).
|
||||||
|
|
||||||
|
Замена монолитному `repo-init`: Phase A (GitHub remote + branch protection)
|
||||||
|
мигрирована без изменений, Phase B (ручное scaffolding) заменена на cookiecutter
|
||||||
|
шаблоны из `.opencode/templates/<type>/` (issue #229 / PR #235). Проверка
|
||||||
|
архитектуры — через `project-status` tool (issue #228 / PR #233).
|
||||||
|
|
||||||
|
## ПРОТОКОЛ (ЖЁСТКО)
|
||||||
|
|
||||||
|
1. Определи flow (вопрос юзеру, см. ниже).
|
||||||
|
2. init flow → шаги 1-6 (см. ниже).
|
||||||
|
3. check flow → шаги 1-3 (см. ниже).
|
||||||
|
4. После каждого шага → 1 строка прогресса юзеру (формат: `✅ <step> — <done>`).
|
||||||
|
|
||||||
|
### ЗАПРЕЩЕНО
|
||||||
|
|
||||||
|
- Запускать cookiecutter / git / gh напрямую из main agent — делегируй subagent
|
||||||
|
(Template INIT, Template GITHUB). Main agent = оркестратор: вопросы юзеру +
|
||||||
|
`project-status` tool (read-only oracle) + делегирование.
|
||||||
|
- `gh repo create` БЕЗ предварительного git init + initial commit (bare-repo →
|
||||||
|
empty push → main branch не появляется → branch protection падает).
|
||||||
|
- Импровизировать тип проекта вне `VALID_TYPES` из `spec-status.py` (см. ниже).
|
||||||
|
- Запускать `/run-pipeline` автоматически — стоп после init/check, дальше юзер
|
||||||
|
сам.
|
||||||
|
|
||||||
|
### Остановы
|
||||||
|
|
||||||
|
- Subagent error → 1 retry, потом STOP + report пользователю.
|
||||||
|
- cookiecutter не установлен → WARN + инструкция, STOP init flow.
|
||||||
|
- Template для типа не найден → WARN, STOP init flow (предложи check flow или
|
||||||
|
тип из доступных).
|
||||||
|
- `project-status` tool вернул `⚠️ ...failed` → WARN, сообщи пользователю,
|
||||||
|
продолжай без отчёта (не блокирующий).
|
||||||
|
|
||||||
|
## Определение flow
|
||||||
|
|
||||||
|
Вопрос юзеру (один вопрос, multiple choice):
|
||||||
|
|
||||||
|
```
|
||||||
|
Это новый проект или проверка существующего?
|
||||||
|
[1] init — новый проект: cookiecutter по типу + GitHub remote + branch protection
|
||||||
|
[2] check — аудит существующего: project-status (структура, роуты, качество, README, infra)
|
||||||
|
```
|
||||||
|
|
||||||
|
Если юзер выбрал init, но cwd уже git-репо с коммитами и файлами → предложи
|
||||||
|
check flow (см. Граничные случаи). Если cwd пустой или юзер подтвердил init →
|
||||||
|
init flow.
|
||||||
|
|
||||||
|
## init flow
|
||||||
|
|
||||||
|
### Шаг 1: Вопрос — тип проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
Выбери тип проекта (из spec-status VALID_TYPES):
|
||||||
|
[1] backend — FastAPI + Tortoise, REST API (cookiecutter template)
|
||||||
|
[2] fullstack — backend + SvelteKit + Tailwind v4 + shadcn-svelte dashboard (TypeScript, cookiecutter template)
|
||||||
|
[3] cli — Python CLI tool, Typer (cookiecutter template)
|
||||||
|
[4] mcp-server — MCP + REST сервер (нет cookiecutter template — ручная инициализация)
|
||||||
|
[5] bot — Telegram bot, aiogram 3 (нет cookiecutter template — ручная инициализация)
|
||||||
|
[6] worker — Prefect flows (нет cookiecutter template — ручная инициализация)
|
||||||
|
```
|
||||||
|
|
||||||
|
Cookiecutter templates доступны для типов: `backend`, `fullstack`, `cli`
|
||||||
|
(директории в `.opencode/templates/`). Для `mcp-server`, `bot`, `worker`
|
||||||
|
шаблонов нет → WARN: "cookiecutter template not found for type `<type>`.
|
||||||
|
Доступные: backend, fullstack, cli. Для остальных типов используй check flow
|
||||||
|
или создай issue на добавление template." → STOP init flow.
|
||||||
|
|
||||||
|
### Шаг 2: Вопрос — имя, описание, опции
|
||||||
|
|
||||||
|
```
|
||||||
|
Имя проекта (kebab-case, станет package name и GitHub repo name): ___
|
||||||
|
Описание (1 строка): ___
|
||||||
|
GitHub owner: ___
|
||||||
|
use_auth: [1] no (default) / [2] yes
|
||||||
|
use_db: [1] yes (default) / [2] no
|
||||||
|
```
|
||||||
|
|
||||||
|
Опции `use_auth` / `use_db` — переменные cookiecutter (см. `cookiecutter.json`).
|
||||||
|
Значения: `"no"` или `"yes"` (строки, lowercase).
|
||||||
|
|
||||||
|
### Шаг 3: Делегирование — cookiecutter + git init + initial commit
|
||||||
|
|
||||||
|
Subagent (general) — Template INIT ниже. Cookiecutter рендерит проект в
|
||||||
|
`./<project_name>/`, post_gen_project hook удаляет файлы условные на
|
||||||
|
`use_auth`/`use_db`. Затем git init + initial commit внутри `./<project_name>/`.
|
||||||
|
|
||||||
|
### Шаг 4: Делегирование — GitHub remote + branch protection
|
||||||
|
|
||||||
|
Subagent (general) — Template GITHUB ниже. Мигрировано из repo-init Phase A
|
||||||
|
(steps 1-3) БЕЗ изменений: `gh repo create`, squash-only merge settings, branch
|
||||||
|
protection на main. Требует локальный git-репо с initial commit (из шага 3).
|
||||||
|
|
||||||
|
### Шаг 5: Оркестратор — project-status
|
||||||
|
|
||||||
|
Вызови `project-status` tool напрямую (read-only oracle, ALLOWED для
|
||||||
|
оркестратора — как `pipeline-status` / `spec-status`):
|
||||||
|
|
||||||
|
```
|
||||||
|
project-status({})
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool вернёт отчёт: `Project: <type>`, 8 групп `[OK]/[WARN]`, `Итог:`,
|
||||||
|
`Рекомендации:`. Покажи отчёт юзеру. Если tool вернул `⚠️ ...failed` → WARN,
|
||||||
|
продолжай без отчёта.
|
||||||
|
|
||||||
|
> README у свежего проекта отсутствует (issue #269) — README генерируется
|
||||||
|
> позже через repo-readme skill по ручному вызову. Поэтому группа README
|
||||||
|
> даст WARN «README.md отсутствует» (issue #275: exit code всегда 0,
|
||||||
|
> все проверки WARN). Это ожидаемое поведение, не ошибка — упомяни в отчёте.
|
||||||
|
|
||||||
|
### Шаг 6: Финальный репорт
|
||||||
|
|
||||||
|
```
|
||||||
|
Project <project_name> created at ./<project_name>/.
|
||||||
|
GitHub: https://github.com/<owner>/<project_name>
|
||||||
|
Branch protection: main (PR + required_status_checks + linear history)
|
||||||
|
Project-status: <summary из шага 5>
|
||||||
|
README: отсутствует (WARN у project-status) — появится позже через repo-readme
|
||||||
|
Дальше: /run-pipeline для реализации фич, или /spec для генерации spec.
|
||||||
|
```
|
||||||
|
|
||||||
|
## check flow
|
||||||
|
|
||||||
|
### Шаг 1: Оркестратор — project-status
|
||||||
|
|
||||||
|
```
|
||||||
|
project-status({})
|
||||||
|
```
|
||||||
|
|
||||||
|
Issue #275: exit code всегда 0 (информационный режим). ``--check`` принимается
|
||||||
|
для CLI совместимости, но больше не форсирует exit 1. Для пропуска медленных
|
||||||
|
remote-проверок (branch protection via gh) — `project-status({ fast: true })`.
|
||||||
|
|
||||||
|
### Шаг 2: Оркестратор — отчёт + рекомендации
|
||||||
|
|
||||||
|
Покажи полный отчёт юзеру. В разделе `Рекомендации:` — список WARN-чеков с
|
||||||
|
путями (issue #275: все проверки WARN, не FAIL). Сгруппируй по категориям
|
||||||
|
(Структура / Качество кода / Тесты / README / Infra / Coverage).
|
||||||
|
|
||||||
|
### Шаг 3: Вопрос — чинить?
|
||||||
|
|
||||||
|
```
|
||||||
|
Найдены проблемы: <N WARN>.
|
||||||
|
Запустить fix-subagents для рекомендаций?
|
||||||
|
[1] да — делегируй subagent(ов) для каждого WARN
|
||||||
|
[2] нет — только отчёт, я починю сам
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `да` → для каждого WARN из `Рекомендации:` создай subagent (general) с
|
||||||
|
Template FIX (ниже), передав путь и описание проблемы. Subagent чинит, коммитит
|
||||||
|
через `commit` tool, push. Один WARN = один subagent (последовательно, не
|
||||||
|
параллельно — см. AGENTS.md Linear Execution). После всех фиксов → re-run
|
||||||
|
`project-status` для верификации.
|
||||||
|
|
||||||
|
Если `нет` → STOP, отчёт у юзера.
|
||||||
|
|
||||||
|
## Граничные случаи
|
||||||
|
|
||||||
|
- **Существующий репо (не пустой)** → init flow: предложи check flow. Если юзер
|
||||||
|
настаивает на init → cookiecutter создаст `./<project_name>/` рядом (не
|
||||||
|
перезапишет текущий репо). Уточни: "cwd уже git-репо с файлами. Init создаст
|
||||||
|
новый проект в подкаталоге `./<project_name>/`. Продолжить? [1] да / [2] нет,
|
||||||
|
лучше check flow".
|
||||||
|
- **GitHub repo уже существует** → skip `gh repo create`, только branch
|
||||||
|
protection (если ещё не настроена). Subagent проверяет: `gh repo view
|
||||||
|
<owner>/<name>` — если существует, пропускает create, переходит к settings +
|
||||||
|
branch protection.
|
||||||
|
- **cookiecutter не установлен** → WARN: "cookiecutter не найден. Установи:
|
||||||
|
`uv tool install cookiecutter` (или `pipx install cookiecutter`). После
|
||||||
|
установки повтори init." → STOP init flow.
|
||||||
|
- **project-status не найден** → WARN: "project-status tool не доступен (issue
|
||||||
|
#228 / PR #233 не завершён или tool не зарегистрирован). Пропускаю
|
||||||
|
project-status проверку." → продолжай без отчёта (не блокирующий).
|
||||||
|
- **Template для типа не найден** (mcp-server/bot/worker) → WARN (см. Шаг 1).
|
||||||
|
- **gh auth не настроен** → subagent упадёт на `gh repo create`. Сообщи юзеру:
|
||||||
|
"запусти `gh auth login` и повтори".
|
||||||
|
|
||||||
|
## Prompt templates
|
||||||
|
|
||||||
|
### Template INIT (cookiecutter + git init + initial commit)
|
||||||
|
|
||||||
|
```
|
||||||
|
Создай новый проект типа <type> с именем <project_name>.
|
||||||
|
Контекст: init flow project-template skill, cwd = <cwd>.
|
||||||
|
|
||||||
|
1. Проверь cookiecutter: `cookiecutter --version`. Если не установлен → STOP,
|
||||||
|
верни: "cookiecutter не установлен. Установи: `uv tool install cookiecutter`".
|
||||||
|
2. Запусти cookiecutter (no-input, переменные из ответов юзера):
|
||||||
|
`cookiecutter .opencode/templates/<type>/ --no-input \
|
||||||
|
project_name=<project_name> \
|
||||||
|
project_type=<type> \
|
||||||
|
description="<description>" \
|
||||||
|
use_auth=<auth> \
|
||||||
|
use_db=<db> \
|
||||||
|
python_version=3.13`
|
||||||
|
Cookiecutter создаст каталог `./<project_name>/` с рендеренным проектом.
|
||||||
|
post_gen_project hook удалит файлы условные на use_auth/use_db.
|
||||||
|
3. `cd <project_name>` (все дальнейшие команды — внутри этого каталога).
|
||||||
|
4. `git init`
|
||||||
|
5. `git add .` затем `git status` — проверь staged set (только файлы проекта,
|
||||||
|
без лишнего). Если лишнее — `git restore --staged <file>`.
|
||||||
|
6. `commit({ message: "chore: initial commit" })` tool (НЕ raw `git commit` —
|
||||||
|
заблокирован deny).
|
||||||
|
7. Верни: "done: project created at ./<project_name>/, git init + initial commit".
|
||||||
|
|
||||||
|
README.md в рендеренном проекте НЕТ (issue #269) — шаблоны его больше не
|
||||||
|
содержат. README появится позже через repo-readme skill по ручному вызову
|
||||||
|
(сначала юзер смотрит контент и cover.png из draw-image). project-status на
|
||||||
|
свежем проекте даст WARN по отсутствующему README — это ожидаемо, не чинить.
|
||||||
|
|
||||||
|
Если найдёшь баг вне scope — загрузи skill `bug-discovery` через
|
||||||
|
`skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template GITHUB (gh repo create + settings + branch protection)
|
||||||
|
|
||||||
|
> Мигрировано из repo-init Phase A (steps 1-3) БЕЗ изменений. Требует локальный
|
||||||
|
> git-репо с initial commit (из Template INIT).
|
||||||
|
|
||||||
|
```
|
||||||
|
Настрой GitHub remote для проекта <project_name> (cwd = <cwd>/<project_name>).
|
||||||
|
owner = <owner>, visibility = public (или private если internal).
|
||||||
|
|
||||||
|
0. Проверь: GitHub repo уже существует?
|
||||||
|
`gh repo view <owner>/<project_name>` — если exit 0, skip step 1 (create),
|
||||||
|
переходи к step 2 (settings) и step 3 (branch protection).
|
||||||
|
|
||||||
|
1. Создание репозитория:
|
||||||
|
`gh repo create <owner>/<project_name> --public --source=. --remote=origin --push`
|
||||||
|
(или --private если internal)
|
||||||
|
После создания: `gh auth setup-git`
|
||||||
|
|
||||||
|
2. Настройки репозитория (squash-only merge, auto-delete branch):
|
||||||
|
`gh api repos/<owner>/<project_name> \
|
||||||
|
--method PATCH \
|
||||||
|
-f allow_squash_merge=true \
|
||||||
|
-f allow_merge_commit=false \
|
||||||
|
-f allow_rebase_merge=false \
|
||||||
|
-f delete_branch_on_merge=true \
|
||||||
|
-f squash_merge_commit_title=COMMIT_OR_PR_TITLE \
|
||||||
|
-f squash_merge_commit_message=COMMIT_MESSAGES`
|
||||||
|
|
||||||
|
3. Защита ветки main (требовать PR, required_status_checks, linear history):
|
||||||
|
`gh api repos/<owner>/<project_name>/rules/branches/main \
|
||||||
|
--method POST \
|
||||||
|
-F target=branch \
|
||||||
|
-f enforcement=active \
|
||||||
|
--input - <<'EOF'
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"ref_name": {
|
||||||
|
"include": ["refs/heads/main"],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"type": "pull_request",
|
||||||
|
"parameters": {
|
||||||
|
"required_approving_review_count": 0,
|
||||||
|
"dismiss_stale_reviews_on_push": false,
|
||||||
|
"require_code_owner_review": false,
|
||||||
|
"require_last_push_approval": false,
|
||||||
|
"required_review_thread_resolution": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "required_status_checks",
|
||||||
|
"parameters": {
|
||||||
|
"strict_required_status_checks": true,
|
||||||
|
"do_not_enforce_on_create": false,
|
||||||
|
"required_status_checks": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "deletion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "non_fast_forward"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF`
|
||||||
|
|
||||||
|
`required_status_checks` заполняется именами CI-джобов после первого пуша
|
||||||
|
(имена из cookiecutter CI: `lint`, `typecheck`, `test`, `complexity` для
|
||||||
|
Python; `check` для JS/TS). На этом этапе оставь пустой массив — обновится
|
||||||
|
после первого CI-прогона.
|
||||||
|
|
||||||
|
4. Верни: "done: GitHub remote created/verified, squash-only merge, branch
|
||||||
|
protection on main".
|
||||||
|
|
||||||
|
Если найдёшь баг вне scope — загрузи skill `bug-discovery` через
|
||||||
|
`skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template FIX (check flow — починить WARN из project-status)
|
||||||
|
|
||||||
|
```
|
||||||
|
Почини проблему из project-status отчёта.
|
||||||
|
Категория: <category> (Структура / Качество кода / Тесты / README / Infra / Coverage)
|
||||||
|
Проблема: <name>: <detail>
|
||||||
|
Путь: <path из рекомендации>
|
||||||
|
|
||||||
|
1. Прочитай контекст проблемы (файл по пути из рекомендации).
|
||||||
|
2. Минимальный фикс: добавь/исправь только то, что указано в рекомендации.
|
||||||
|
Не рефактори unrelated код.
|
||||||
|
3. Перед коммитом — `git status` для проверки staged set (`commit` tool НЕ
|
||||||
|
делает `git add` — коммитит только staged; используй
|
||||||
|
`git add <конкретные-пути>`, НЕ `git add -A`).
|
||||||
|
4. `commit({ message: "fix(<scope>): <description>" })` tool (НЕ raw
|
||||||
|
`git commit`), push.
|
||||||
|
5. Верни: "done: fixed <name>, commit <hash>".
|
||||||
|
|
||||||
|
Если найдёшь баг вне scope — загрузи skill `bug-discovery` через
|
||||||
|
`skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам.
|
||||||
|
```
|
||||||
|
|
||||||
|
## VALID_TYPES (из spec-status.py)
|
||||||
|
|
||||||
|
```
|
||||||
|
backend, fullstack, mcp-server, cli, bot, worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Cookiecutter templates доступны для: `backend`, `fullstack`, `cli`
|
||||||
|
(директории `.opencode/templates/<type>/`).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Main agent = оркестратор: вопросы юзеру + `project-status` tool (read-only) +
|
||||||
|
делегирование subagent'ам (Template INIT / GITHUB / FIX). Не делает
|
||||||
|
cookiecutter/git/gh напрямую.
|
||||||
|
- `project-status` tool — read-only oracle, ALLOWED для оркестратора (как
|
||||||
|
`pipeline-status` / `spec-status`).
|
||||||
|
- init flow порядок: cookiecutter → git init/commit → gh repo create → branch
|
||||||
|
protection → project-status. Не меняй порядок. README в init flow НЕ
|
||||||
|
генерируется — только по ручному вызову repo-readme (issue #269).
|
||||||
|
- check flow: project-status → отчёт → рекомендации → subagents (последовательно).
|
||||||
|
- Subagent error → 1 retry, потом STOP + report.
|
||||||
|
- Совместим с spec-pipeline: Phase 8 EXECUTE (spec/SKILL.md Template I) может
|
||||||
|
вызывать project-template init для scaffolding issue.
|
||||||
|
- `commit` tool НЕ делает `git add` — коммитит только staged. Используй
|
||||||
|
`git add <конкретные-пути>`, НЕ `git add -A`.
|
||||||
|
|
@ -1,759 +0,0 @@
|
||||||
---
|
|
||||||
name: repo-init
|
|
||||||
description: Sequential checklist: create GitHub remote → configure settings/branch protection → scaffold project files (Python/JS). Use when starting a new repo. Also when user says "новый репо", "создай репозиторий", "настрой репо".
|
|
||||||
---
|
|
||||||
|
|
||||||
# Repo Init
|
|
||||||
|
|
||||||
Полный чек-лист инициализации нового репозитория. Все шаблоны — внутри, берутся из эталонных репозиториев (reference repos).
|
|
||||||
|
|
||||||
## Фазы
|
|
||||||
|
|
||||||
- **Phase A — GitHub remote** (шаги 1-3, выполняется один раз): создание репо, настройки merge, защита ветки. Требует локального git-репо с initial commit.
|
|
||||||
- **Phase B — Project scaffolding** (шаги 4-9, по шаблонам): Python/JS файлы, Dependabot, LICENSE, .editorconfig, pre-commit, верификация. Можно повторно использовать для существующего репо (skip Phase A).
|
|
||||||
|
|
||||||
## Содержание
|
|
||||||
|
|
||||||
1. [Создание репозитория](#1-создание-репозитория)
|
|
||||||
2. [Настройки репозитория](#2-настройки-репозитория)
|
|
||||||
3. [Защита ветки main](#3-защита-ветки-main)
|
|
||||||
4. [Python-проект](#4-python-проект)
|
|
||||||
5. [JS/TS-проект](#5-jsts-проект)
|
|
||||||
6. [Dependabot](#6-dependabot)
|
|
||||||
7. [Общие файлы](#7-общие-файлы)
|
|
||||||
8. [Установка pre-commit](#8-установка-pre-commit)
|
|
||||||
9. [Чек-лист верификации](#9-чек-лист-верификации)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase A — GitHub remote
|
|
||||||
|
|
||||||
> Шаги 1-3 выполняются один раз для нового репо. Требуют локального git-репо с initial commit.
|
|
||||||
|
|
||||||
## 0. Prerequisite: git init + initial commit
|
|
||||||
|
|
||||||
Перед `gh repo create --source=.` локальный каталог должен быть git-репо с хотя бы одним коммитом (`--source=.` пушит текущую ветку; без коммита — пустой репо).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git init
|
|
||||||
echo "# <repo-name>" > README.md
|
|
||||||
git add README.md
|
|
||||||
git status # проверь staged set — только README.md, без лишнего
|
|
||||||
```
|
|
||||||
|
|
||||||
> `commit` tool НЕ делает `git add` — коммитит только уже staged файлы. Если
|
|
||||||
> в индексе лишнее (например `memory-save` stage'нул всё через `git add -A`)
|
|
||||||
> — не коммить: сначала `git restore --staged <file>` или не stage'и его
|
|
||||||
> изначально. Используй `git add <конкретные-пути>`, НЕ `git add -A`.
|
|
||||||
|
|
||||||
Затем через `commit` tool (НЕ raw `git commit` — заблокирован deny):
|
|
||||||
|
|
||||||
```
|
|
||||||
commit({ message: "chore: initial commit" })
|
|
||||||
```
|
|
||||||
|
|
||||||
Если bare-repo без initial commit — `gh repo create --source=.` создаст remote, но push будет пустым, а main branch не появится → branch protection упадёт.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Создание репозитория
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh repo create <owner>/<repo-name> --public --source=. --remote=origin --push
|
|
||||||
```
|
|
||||||
|
|
||||||
Или приватный (если internal):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh repo create <owner>/<repo-name> --private --source=. --remote=origin --push
|
|
||||||
```
|
|
||||||
|
|
||||||
После создания — настроить git auth:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh auth setup-git
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Настройки репозитория
|
|
||||||
|
|
||||||
Squash-only merge, auto-delete branch после merge:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api repos/<owner>/<repo-name> \
|
|
||||||
--method PATCH \
|
|
||||||
-f allow_squash_merge=true \
|
|
||||||
-f allow_merge_commit=false \
|
|
||||||
-f allow_rebase_merge=false \
|
|
||||||
-f delete_branch_on_merge=true \
|
|
||||||
-f squash_merge_commit_title=COMMIT_OR_PR_TITLE \
|
|
||||||
-f squash_merge_commit_message=COMMIT_MESSAGES
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Защита ветки main
|
|
||||||
|
|
||||||
Требовать PR, требовать status checks (CI), linear history:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api repos/<owner>/<repo-name>/rules/branches/main \
|
|
||||||
--method POST \
|
|
||||||
-F target=branch \
|
|
||||||
-f enforcement=active \
|
|
||||||
--input - <<'EOF'
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"ref_name": {
|
|
||||||
"include": ["refs/heads/main"],
|
|
||||||
"exclude": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"type": "pull_request",
|
|
||||||
"parameters": {
|
|
||||||
"required_approving_review_count": 0,
|
|
||||||
"dismiss_stale_reviews_on_push": false,
|
|
||||||
"require_code_owner_review": false,
|
|
||||||
"require_last_push_approval": false,
|
|
||||||
"required_review_thread_resolution": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "required_status_checks",
|
|
||||||
"parameters": {
|
|
||||||
"strict_required_status_checks": true,
|
|
||||||
"do_not_enforce_on_create": false,
|
|
||||||
"required_status_checks": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "deletion"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "non_fast_forward"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
`required_status_checks` заполняется именами CI-джобов после первого пуша (см. шаблоны CI ниже). Имена джобов: `lint`, `typecheck`, `test`, `complexity` (Python) или `check` (JS/TS).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase B — Project scaffolding
|
|
||||||
|
|
||||||
> Шаги 4-9 — шаблоны файлов для Python или JS/TS проекта. Можно применять к существующему репо (skip Phase A). Не зависят от GitHub remote.
|
|
||||||
|
|
||||||
## 4. Python-проект
|
|
||||||
|
|
||||||
### Инструменты
|
|
||||||
|
|
||||||
| Инструмент | Назначение | Конфиг в |
|
|
||||||
|---|---|---|
|
|
||||||
| **uv** | Package manager, virtual env | `pyproject.toml` (build + deps) |
|
|
||||||
| **ruff** | Linter + formatter (замена flake8/isort/black) | `pyproject.toml` `[tool.ruff]` |
|
|
||||||
| **mypy** | Строгая типизация | `pyproject.toml` `[tool.mypy]` |
|
|
||||||
| **pytest** + **pytest-cov** | Тесты + покрытие | `pyproject.toml` `[tool.pytest]` |
|
|
||||||
| **xenon** | Анализ сложности кода | CI workflow |
|
|
||||||
| **pre-commit** | Git hooks (ruff + mypy перед коммитом) | `.pre-commit-config.yaml` |
|
|
||||||
| **hatchling** | Build backend (wheel) | `pyproject.toml` `[build-system]` |
|
|
||||||
|
|
||||||
### pyproject.toml
|
|
||||||
|
|
||||||
> Заменить `<package-name>`, `<description>`, `<owner>/<repo>` на реальные значения. `additional_dependencies` в pre-commit — список runtime-зависимостей (для mypy).
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "<package-name>"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "<description>"
|
|
||||||
readme = "README.md"
|
|
||||||
license = "MIT"
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
authors = [{ name = "slaid098" }]
|
|
||||||
keywords = []
|
|
||||||
classifiers = [
|
|
||||||
"Development Status :: 4 - Beta",
|
|
||||||
"Environment :: Console",
|
|
||||||
"Intended Audience :: End Users/Desktop",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Operating System :: Microsoft :: Windows",
|
|
||||||
"Operating System :: POSIX :: Linux",
|
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Programming Language :: Python :: 3.13",
|
|
||||||
"Programming Language :: Python :: 3.14",
|
|
||||||
]
|
|
||||||
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-cov>=5.0",
|
|
||||||
"pytest-timeout>=2.2",
|
|
||||||
"mypy>=1.10",
|
|
||||||
"ruff>=0.5",
|
|
||||||
"xenon>=0.9",
|
|
||||||
"pre-commit>=3.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
Homepage = "https://github.com/slaid098/<repo>"
|
|
||||||
Repository = "https://github.com/slaid098/<repo>"
|
|
||||||
Issues = "https://github.com/slaid098/<repo>/issues"
|
|
||||||
Changelog = "https://github.com/slaid098/<repo>/blob/main/CHANGELOG.md"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["src/<package_name>"]
|
|
||||||
|
|
||||||
# ── Ruff ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
target-version = "py312"
|
|
||||||
line-length = 100
|
|
||||||
src = ["src", "tests"]
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = [
|
|
||||||
"E", "W", # pycodestyle
|
|
||||||
"F", # pyflakes
|
|
||||||
"I", # isort
|
|
||||||
"B", # bugbear
|
|
||||||
"UP", # pyupgrade
|
|
||||||
"SIM", # simplify
|
|
||||||
"C90", # mccabe complexity
|
|
||||||
"PL", # pylint
|
|
||||||
"RUF", # ruff-specific
|
|
||||||
"S", # bandit (security)
|
|
||||||
"TRY", # tryceratops (exception handling)
|
|
||||||
"LOG", # flake8-logging
|
|
||||||
]
|
|
||||||
ignore = [
|
|
||||||
"S101", # assert in tests
|
|
||||||
"S311", # pseudo-random for non-crypto use
|
|
||||||
"RUF001", # ambiguous Cyrillic chars (we write in Russian)
|
|
||||||
"RUF002", # same for docstrings
|
|
||||||
"RUF003", # same for comments
|
|
||||||
"TRY003", # long messages outside exception class
|
|
||||||
"PLR2004", # magic values in tests
|
|
||||||
"S106", # hardcoded passwords in tests
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.lint.mccabe]
|
|
||||||
max-complexity = 10
|
|
||||||
|
|
||||||
[tool.ruff.lint.pylint]
|
|
||||||
max-args = 5
|
|
||||||
max-branches = 12
|
|
||||||
max-returns = 5
|
|
||||||
max-statements = 50
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607"]
|
|
||||||
|
|
||||||
# ── mypy ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
python_version = "3.12"
|
|
||||||
strict = true
|
|
||||||
warn_return_any = true
|
|
||||||
disallow_untyped_defs = true
|
|
||||||
disallow_incomplete_defs = true
|
|
||||||
check_untyped_defs = true
|
|
||||||
no_implicit_optional = true
|
|
||||||
warn_redundant_casts = true
|
|
||||||
warn_unused_ignores = true
|
|
||||||
|
|
||||||
# ── pytest ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
addopts = "--cov=<package_name> --cov-report=term-missing --cov-fail-under=90 --timeout=120"
|
|
||||||
testpaths = ["tests"]
|
|
||||||
|
|
||||||
# ── coverage ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
|
||||||
source = ["src/<package_name>"]
|
|
||||||
branch = true
|
|
||||||
|
|
||||||
[tool.coverage.report]
|
|
||||||
exclude_lines = [
|
|
||||||
"pragma: no cover",
|
|
||||||
"if __name__ == .__main__.:",
|
|
||||||
"if TYPE_CHECKING:",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### .pre-commit-config.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
repos:
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
||||||
rev: v0.15.0
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
args: [--fix]
|
|
||||||
- id: ruff-format
|
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
||||||
rev: v1.18.2
|
|
||||||
hooks:
|
|
||||||
- id: mypy
|
|
||||||
additional_dependencies: []
|
|
||||||
```
|
|
||||||
|
|
||||||
`additional_dependencies` — список runtime-зависимостей проекта (из `[project.dependencies]`), чтобы mypy мог резолвить типы.
|
|
||||||
|
|
||||||
### .gitignore (Python)
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.egg-info/
|
|
||||||
*.egg
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
.eggs/
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
|
|
||||||
# Environment / secrets
|
|
||||||
.env
|
|
||||||
*.env
|
|
||||||
!.env.template
|
|
||||||
|
|
||||||
# Testing / quality caches
|
|
||||||
.pytest_cache/
|
|
||||||
.coverage
|
|
||||||
htmlcov/
|
|
||||||
.mypy_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
```
|
|
||||||
|
|
||||||
### .github/workflows/ci.yml (Python)
|
|
||||||
|
|
||||||
4 job'а: lint → typecheck → test (matrix) → complexity.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
- run: uv sync --extra dev
|
|
||||||
- run: uv run ruff check src/ tests/
|
|
||||||
- run: uv run ruff format --check src/ tests/
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
- run: uv sync --extra dev
|
|
||||||
- run: uv run mypy src/
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
python: ["3.12", "3.13", "3.14"]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
- run: uv sync --extra dev --python ${{ matrix.python }}
|
|
||||||
- run: uv run pytest
|
|
||||||
|
|
||||||
complexity:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
- run: uv sync --extra dev
|
|
||||||
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Структура проекта (Python)
|
|
||||||
|
|
||||||
```
|
|
||||||
<repo>/
|
|
||||||
├── .github/
|
|
||||||
│ ├── workflows/
|
|
||||||
│ │ └── ci.yml
|
|
||||||
│ └── dependabot.yml
|
|
||||||
├── src/
|
|
||||||
│ └── <package_name>/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── py.typed
|
|
||||||
├── tests/
|
|
||||||
│ └── __init__.py
|
|
||||||
├── .pre-commit-config.yaml
|
|
||||||
├── .gitignore
|
|
||||||
├── LICENSE
|
|
||||||
├── pyproject.toml
|
|
||||||
├── README.md
|
|
||||||
└── uv.lock
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. JS/TS-проект
|
|
||||||
|
|
||||||
### Инструменты
|
|
||||||
|
|
||||||
| Инструмент | Назначение | Конфиг в |
|
|
||||||
|---|---|---|
|
|
||||||
| **npm** | Package manager | `package.json` |
|
|
||||||
| **Biome** | Linter + formatter (замена ESLint/Prettier) | `biome.json` |
|
|
||||||
| **TypeScript** | Строгая типизация | `tsconfig.json` |
|
|
||||||
| **Vitest** + **@vitest/coverage-v8** | Тесты + покрытие | `vitest.config.ts` |
|
|
||||||
| **Knip** | Dead-code detection | `knip.json` |
|
|
||||||
|
|
||||||
### package.json
|
|
||||||
|
|
||||||
> Заменить `<name>`, `<description>` на реальные значения. `entry` в knip.json — точка входа (для tree-shaking анализа).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "<name>",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "<dev-command>",
|
|
||||||
"build": "<build-command>",
|
|
||||||
"lint": "biome check",
|
|
||||||
"format": "biome format --write",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"knip": "knip"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@biomejs/biome": "^1.9.4",
|
|
||||||
"@types/node": "^22.10.0",
|
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
|
||||||
"happy-dom": "^20.10.0",
|
|
||||||
"knip": "^6.24.0",
|
|
||||||
"typescript": "^5.7.0",
|
|
||||||
"vitest": "^3.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### biome.json
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
|
||||||
"vcs": {
|
|
||||||
"enabled": true,
|
|
||||||
"clientKind": "git",
|
|
||||||
"useIgnoreFile": true
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"ignoreUnknown": true,
|
|
||||||
"ignore": ["node_modules", "dist", "coverage"]
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true,
|
|
||||||
"indentStyle": "space",
|
|
||||||
"indentWidth": 2,
|
|
||||||
"lineWidth": 100,
|
|
||||||
"lineEnding": "lf"
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"quoteStyle": "double",
|
|
||||||
"semicolons": "always",
|
|
||||||
"trailingCommas": "all"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"recommended": true,
|
|
||||||
"suspicious": {
|
|
||||||
"noExplicitAny": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### knip.json
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"entry": ["src/index.ts"],
|
|
||||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
|
||||||
"ignore": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### tsconfig.json
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"strict": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["src", "tests"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### vitest.config.ts
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
include: ["tests/**/*.test.ts"],
|
|
||||||
coverage: {
|
|
||||||
provider: "v8",
|
|
||||||
reporter: ["text", "html"],
|
|
||||||
thresholds: {
|
|
||||||
lines: 60,
|
|
||||||
functions: 60,
|
|
||||||
branches: 60,
|
|
||||||
statements: 60,
|
|
||||||
},
|
|
||||||
exclude: [
|
|
||||||
"tests/**",
|
|
||||||
"dist/**",
|
|
||||||
"vitest.config.ts",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### .gitignore (JS/TS)
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
coverage/
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### .github/workflows/ci.yml (JS/TS)
|
|
||||||
|
|
||||||
Single job: lint → typecheck → knip → test → build.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: "npm"
|
|
||||||
- run: npm ci
|
|
||||||
- name: Lint (Biome)
|
|
||||||
run: npm run lint
|
|
||||||
- name: Typecheck
|
|
||||||
run: npm run typecheck
|
|
||||||
- name: Knip
|
|
||||||
run: npm run knip
|
|
||||||
- name: Test (Vitest + Coverage)
|
|
||||||
run: npm run test
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Структура проекта (JS/TS)
|
|
||||||
|
|
||||||
```
|
|
||||||
<repo>/
|
|
||||||
├── .github/
|
|
||||||
│ ├── workflows/
|
|
||||||
│ │ └── ci.yml
|
|
||||||
│ └── dependabot.yml
|
|
||||||
├── src/
|
|
||||||
│ └── index.ts
|
|
||||||
├── tests/
|
|
||||||
├── .gitignore
|
|
||||||
├── biome.json
|
|
||||||
├── knip.json
|
|
||||||
├── package.json
|
|
||||||
├── package-lock.json
|
|
||||||
├── tsconfig.json
|
|
||||||
├── vitest.config.ts
|
|
||||||
├── LICENSE
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Dependabot
|
|
||||||
|
|
||||||
Автоматическое обновление зависимостей. Еженедельно, 5 PR max.
|
|
||||||
|
|
||||||
### Python (uv/pip)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: "pip"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### JS/TS (npm)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Общие файлы
|
|
||||||
|
|
||||||
### LICENSE (MIT)
|
|
||||||
|
|
||||||
```
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 slaid098
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
```
|
|
||||||
|
|
||||||
### .editorconfig
|
|
||||||
|
|
||||||
```ini
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.{py,toml}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.{ts,tsx,js,jsx,json,yml,yaml,css,md}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Установка pre-commit
|
|
||||||
|
|
||||||
### Python
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync --extra dev
|
|
||||||
uv run pre-commit install
|
|
||||||
uv run pre-commit run --all-files
|
|
||||||
```
|
|
||||||
|
|
||||||
### JS/TS
|
|
||||||
|
|
||||||
Pre-commit hooks не используются. Все quality gates — в CI (lint → typecheck → knip → test → build).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Чек-лист верификации
|
|
||||||
|
|
||||||
- [ ] Репозиторий создан (`gh repo create`)
|
|
||||||
- [ ] `gh auth setup-git` выполнен (push/pull работает)
|
|
||||||
- [ ] Squash-only merge, delete branch on merge (Step 2)
|
|
||||||
- [ ] Branch protection на main (Step 3)
|
|
||||||
- [ ] CI пайплайн зелёный на первом PR
|
|
||||||
- [ ] Pre-commit hooks установлены (Python) / CI гоняет (JS/TS)
|
|
||||||
- [ ] Dependabot включён (Settings → Code security → Dependabot)
|
|
||||||
- [ ] LICENSE, .gitignore, .editorconfig в репозитории
|
|
||||||
- [ ] `uv.lock` / `package-lock.json` закоммичен
|
|
||||||
|
|
@ -14,11 +14,11 @@ Support block, Quick Start, language switcher). Скилл даёт контек
|
||||||
|
|
||||||
- **Новый репо** → `create-readme` (mode: `create`) — генерирует
|
- **Новый репо** → `create-readme` (mode: `create`) — генерирует
|
||||||
стандартизированный двуязычный README с нуля.
|
стандартизированный двуязычный README с нуля.
|
||||||
- **Проверка существующего README** → `create-readme` (mode: `validate`) —
|
- **Проверка существующего README** → `.opencode/scripts/project-status.py`
|
||||||
проверяет, что структура соответствует стандарту витрины.
|
(`check_readme`) — проверяет, что структура соответствует стандарту витрины.
|
||||||
- **После ручных правок README** → всегда `validate`. Любая правка руками
|
- **После ручных правок README** → всегда проверка через `check_readme`. Любая правка руками
|
||||||
агента (через Edit/Write) может нарушить разделители — после правок
|
агента (через Edit/Write) может нарушить разделители — после правок
|
||||||
обязательна валидация.
|
обязательна проверка.
|
||||||
|
|
||||||
Не генерируй README вручную через Write — структура критична для парсинга
|
Не генерируй README вручную через Write — структура критична для парсинга
|
||||||
витриной. Только через тулзу `create-readme`.
|
витриной. Только через тулзу `create-readme`.
|
||||||
|
|
@ -44,13 +44,16 @@ Support block, Quick Start, language switcher). Скилл даёт контек
|
||||||
`"./assets/cover.png"` — можно не передавать.
|
`"./assets/cover.png"` — можно не передавать.
|
||||||
3. Ручные правки если нужно (агент редактирует файл напрямую через Edit) —
|
3. Ручные правки если нужно (агент редактирует файл напрямую через Edit) —
|
||||||
например, расширить `custom_sections`, поправить формулировки.
|
например, расширить `custom_sections`, поправить формулировки.
|
||||||
4. `create-readme` (mode: `validate`) → проверяет, что структура не нарушена
|
4. Проверка через `.opencode/scripts/project-status.py` (`check_readme`) →
|
||||||
(теперь в т.ч. наличие `assets/cover.png` reference).
|
структура соответствует стандарту витрины (в т.ч. наличие
|
||||||
5. Если `validate` fails → фикс нарушения → re-`validate`. Цикл пока не
|
`assets/cover.png` reference).
|
||||||
|
5. Если проверка fails → фикс нарушения → повторная проверка. Цикл пока не
|
||||||
пройдёт.
|
пройдёт.
|
||||||
|
|
||||||
Локальный режим (по умолчанию): тулза пишет в `file_path` (default
|
Локальный режим (по умолчанию): тулза пишет в `file_path` (default
|
||||||
`README.md`) через `fs.writeFileSync`. Удалённый режим: передай `repo`
|
`README.md`) через `fs.writeFileSync`, путь резолвится относительно
|
||||||
|
рабочей директории сессии (`context.worktree`) — существующий файл
|
||||||
|
перезаписывается. Удалённый режим: передай `repo`
|
||||||
(`owner/name`) — тулза сделает PUT через `gh api
|
(`owner/name`) — тулза сделает PUT через `gh api
|
||||||
repos/{owner}/{repo}/contents/README.md` с base64-контентом и SHA.
|
repos/{owner}/{repo}/contents/README.md` с base64-контентом и SHA.
|
||||||
|
|
||||||
|
|
@ -93,7 +96,7 @@ README ссылается именно на этот путь через `**
|
👉 **[slaid098.dev/contacts](https://slaid098.dev/contacts)**
|
||||||
```
|
```
|
||||||
|
|
||||||
`validate` проверяет: наличие всех 6 пар EN/RU разделителей (tagline + summary +
|
`check_readme` (project-status.py) проверяет: наличие всех 6 пар EN/RU
|
||||||
features), непустой контент между ними, H1 title prefix `# 🚀 `, **cover image
|
разделителей (tagline + summary + features), непустой контент между ними, H1
|
||||||
|
title prefix `# 🚀 `, **cover image
|
||||||
reference `assets/cover.png`** (substring-чек, без проверки существования
|
reference `assets/cover.png`** (substring-чек, без проверки существования
|
||||||
файла), ссылку
|
файла), ссылку
|
||||||
`slaid098.dev/support`, секции Quick Start (EN) и Быстрый старт (RU), language
|
`slaid098.dev/contacts`, секции Quick Start (EN) и Быстрый старт (RU), language
|
||||||
switcher `[English]` / `[Русский]`, заголовок `## 🇷🇺 Русский` (не "Русская
|
switcher `[English]` / `[Русский]`, заголовок `## 🇷🇺 Русский` (не "Русская
|
||||||
версия"), anchor `[Русский](#-русский)` (не `#-русская-версия`). Флагирует
|
версия"), anchor `[Русский](#-русский)` (не `#-русская-версия`). Флагирует
|
||||||
ручной заголовок `## License` / `## LICENSE` / `## Лицензия` как ERROR —
|
ручной заголовок `## License` / `## LICENSE` / `## Лицензия` как FAIL —
|
||||||
дубликат GitHub sidebar (GitHub рендерит license из LICENSE-файла). Шаги
|
дубликат GitHub sidebar (GitHub рендерит license из LICENSE-файла). Шаги
|
||||||
`quick_start_steps_*` не влияют на валидацию — они рендерятся вне delimiter-пар
|
`quick_start_steps_*` не влияют на валидацию — они рендерятся вне delimiter-пар
|
||||||
(summary/features).
|
(summary/features).
|
||||||
|
|
||||||
## 6. Независимость от repo-init
|
## 6. Независимость от project-template
|
||||||
|
|
||||||
- Скилл `repo-init` создаёт **пустой** `README.md` как часть инициализации
|
- Скилл `project-template` (init flow) создаёт проект через cookiecutter —
|
||||||
репо.
|
шаблоны README.md НЕ содержат (issue #269): свежий проект рождается без
|
||||||
- `repo-readme` (через тулзу `create-readme`) **наполняет** его
|
README.
|
||||||
стандартизированным контентом.
|
- `repo-readme` (через тулзу `create-readme`) **создаёт** полный README с нуля
|
||||||
- Может применяться к существующим репо без `repo-init` — тулза перезапишет
|
(delimiter tags, bilingual, cover) по ручному вызову после init.
|
||||||
`README.md` (локально) или обновит через GitHub API (с SHA).
|
- Может применяться к существующим репо без `project-template` — тулза
|
||||||
|
перезапишет `README.md` (локально) или обновит через GitHub API (с SHA).
|
||||||
|
|
||||||
## 7. Параметры тулзы (кратко)
|
## 7. Параметры тулзы (кратко)
|
||||||
|
|
||||||
`create-readme`:
|
`create-readme`:
|
||||||
|
|
||||||
- `mode` — `"create"` | `"validate"` (обязательный).
|
- `mode` — `"create"` (обязательный).
|
||||||
- `repo_name`, `tagline_en`, `tagline_ru`, `why_en`, `what_en`, `why_ru`,
|
- `repo_name`, `tagline_en`, `tagline_ru`, `why_en`, `what_en`, `why_ru`,
|
||||||
`what_ru`, `quick_start`, `features_en`, `features_ru` — обязательны для
|
`what_ru`, `quick_start`, `features_en`, `features_ru` — обязательны для
|
||||||
`create`.
|
`create`.
|
||||||
|
|
@ -313,6 +318,6 @@ Access at [http://localhost:4096](http://localhost:4096)
|
||||||
`❌ tagline_en is required for create mode`.
|
`❌ tagline_en is required for create mode`.
|
||||||
|
|
||||||
**Существующие README** (без tagline delimiter-тегов) станут invalid при
|
**Существующие README** (без tagline delimiter-тегов) станут invalid при
|
||||||
`validate` — `Missing <!-- tagline-en:start --> delimiter` и
|
проверке `check_readme` — `Missing <!-- tagline-en:start --> delimiter` и
|
||||||
`Missing <!-- tagline-ru:start --> delimiter`. Регенерация README через
|
`Missing <!-- tagline-ru:start --> delimiter`. Регенерация README через
|
||||||
`create` (с новыми параметрами) делается отдельным шагом после merge.
|
`create` (с новыми параметрами) делается отдельным шагом после merge.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
---
|
---
|
||||||
name: run-pipeline
|
name: run-pipeline
|
||||||
description: Autonomous PR pipeline executor. Delegates 7 phases to subagents, does not improvise order, does not merge on red CI. Also when user says "запусти пайплайн", "pipeline", "run pipeline".
|
description: Autonomous PR pipeline executor. Delegates 6 phases to subagents, does not improvise order, does not merge on red CI. Also when user says "запусти пайплайн", "pipeline", "run pipeline".
|
||||||
---
|
---
|
||||||
|
|
||||||
# Run Pipeline
|
# Run Pipeline
|
||||||
|
|
||||||
Автономная процедура-loop для проведения PR через 7 фаз. Source of truth для
|
Автономная процедура-loop для проведения PR через 6 фаз. Source of truth для
|
||||||
порядка и действий — `pipeline-status` tool. NEXT action из tool явно
|
порядка и действий — `pipeline-status` tool. NEXT action из tool явно
|
||||||
указывает `subagent_type` + `template` (кроме MERGE — `merge-pr` tool).
|
указывает `subagent_type` + `template` (кроме MERGE — `merge-pr` tool).
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ description: Autonomous PR pipeline executor. Delegates 7 phases to subagents, d
|
||||||
1. Вызови tool `pipeline-status({pr_number: M})` — вернёт статус всех фаз + строку `NEXT: <action>`.
|
1. Вызови tool `pipeline-status({pr_number: M})` — вернёт статус всех фаз + строку `NEXT: <action>`.
|
||||||
2. Если вывод содержит `Status: COMPLETE` → финальный репорт пользователю, exit.
|
2. Если вывод содержит `Status: COMPLETE` → финальный репорт пользователю, exit.
|
||||||
3. Если вывод содержит `AMBIGUOUS` → репорт пользователю с причиной, STOP.
|
3. Если вывод содержит `AMBIGUOUS` → репорт пользователю с причиной, STOP.
|
||||||
4. Иначе — исполни action из строки `NEXT:` (используй prompt templates A-E ниже).
|
4. Иначе — исполни action из строки `NEXT:` (используй prompt templates A, C, D, E, F ниже).
|
||||||
5. 1 строка прогресса пользователю (формат: `✅ <phase> — <action executed>`).
|
5. 1 строка прогресса пользователю (формат: `✅ <phase> — <action executed>`).
|
||||||
6. Re-loop (шаг 1).
|
6. Re-loop (шаг 1).
|
||||||
|
|
||||||
|
|
@ -45,10 +45,7 @@ description: Autonomous PR pipeline executor. Delegates 7 phases to subagents, d
|
||||||
1. Checkout new branch `type/scope/kebab-description` от master.
|
1. Checkout new branch `type/scope/kebab-description` от master.
|
||||||
2. Реализуй по спеке issue (точно, без отклонений). Если спека содержит ошибки,
|
2. Реализуй по спеке issue (точно, без отклонений). Если спека содержит ошибки,
|
||||||
зафикь и продолжай — не додумывай.
|
зафикь и продолжай — не додумывай.
|
||||||
3. Создай handoff + ADR: `bash .opencode/scripts/scaffold-handoff.sh M <slug>`
|
3. Коммиты через `commit({ message: "type(scope): description" })` tool (НЕ
|
||||||
(M — будет PR номер, используй placeholder `<PR-NUMBER>` в handoff
|
|
||||||
frontmatter, потом исправишь после create-pr).
|
|
||||||
4. Коммиты через `commit({ message: "type(scope): description" })` tool (НЕ
|
|
||||||
raw `git commit` — заблокирован deny). Формат: ≤72 chars, English, no
|
raw `git commit` — заблокирован deny). Формат: ≤72 chars, English, no
|
||||||
period, no body unless necessary. Минимум 3-4 логических коммита.
|
period, no body unless necessary. Минимум 3-4 логических коммита.
|
||||||
Перед КАЖДЫМ `commit` — `git status` для проверки staged set. `commit`
|
Перед КАЖДЫМ `commit` — `git status` для проверки staged set. `commit`
|
||||||
|
|
@ -56,43 +53,12 @@ description: Autonomous PR pipeline executor. Delegates 7 phases to subagents, d
|
||||||
`git add <конкретные-пути>`, НЕ `git add -A` (иначе лишние файлы уйдут в
|
`git add <конкретные-пути>`, НЕ `git add -A` (иначе лишние файлы уйдут в
|
||||||
коммит). Если в индексе лишнее (например `memory-save` stage'нул всё
|
коммит). Если в индексе лишнее (например `memory-save` stage'нул всё
|
||||||
через `git add -A`) — не коммить: сначала `git restore --staged <file>`.
|
через `git add -A`) — не коммить: сначала `git restore --staged <file>`.
|
||||||
5. Push ветку (`git push -u origin HEAD`), затем создай PR через tool:
|
4. Push ветку (`git push -u origin HEAD`), затем создай PR через tool:
|
||||||
`create-pr({ title: "type(scope): description", body: "## Что сделано\n...\n\n## Почему\n...\n\nCloses #N", issue_number: N })`.
|
`create-pr({ title: "type(scope): description", body: "## Что сделано\n...\n\n## Почему\n...\n\n## Watch out\n...\n\n## Pending\n...\n\nCloses #N", issue_number: N })`.
|
||||||
6. После получения PR номера — исправь placeholder `<PR-NUMBER>` в handoff
|
PR body ОБЯЗАТЕЛЬНО содержит 4 heading'а: `## Что сделано`, `## Почему`,
|
||||||
frontmatter, отдельный коммит `docs(handoff): set PR number` через
|
`## Watch out`, `## Pending`. Заполни осмысленно (для Watch out/Pending
|
||||||
`commit` tool, push.
|
можно `—` если реально нет контента).
|
||||||
7. Верни PR номер M.
|
5. Верни PR номер M.
|
||||||
Если найдёшь баг вне scope текущей задачи — загрузи skill `bug-discovery` через `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. Сообщи оркестратору: "Created issue #N: ...".
|
|
||||||
```
|
|
||||||
|
|
||||||
### Template B (docs-review)
|
|
||||||
|
|
||||||
```
|
|
||||||
Review PR#M в текущем репо (pre-merge, режим docs).
|
|
||||||
1. `gh pr checkout M`.
|
|
||||||
2. Анализируй структурные изменения: `git diff origin/master...HEAD --stat`.
|
|
||||||
3. Сравни с `docs/project-map/` — обнови если structural changes.
|
|
||||||
4. Валидируй handoff `docs/handoff/pr-M-*.md`: 4 секции (Что сделано, Почему,
|
|
||||||
Pending, Watch out) заполнены осмысленно (не пустые плейсхолдеры).
|
|
||||||
5. Валидируй ADR `docs/decisions/*-pr-M-*.md`: 4 секции (Статус, Контекст,
|
|
||||||
Решение, Альтернативы).
|
|
||||||
6. Если криво — почини (edit: allow).
|
|
||||||
7. `git add docs/project-map/ docs/handoff/ docs/decisions/` (add — НЕ
|
|
||||||
заблокирован). Перед `commit` — `git status` для проверки staged set
|
|
||||||
(`commit` tool НЕ делает `git add` — коммитит только staged; НЕ
|
|
||||||
`git add -A`, иначе лишние файлы уйдут в коммит). Затем
|
|
||||||
`commit({ message: "docs: update project map + handoff + ADR" })` tool,
|
|
||||||
и `git push`.
|
|
||||||
8. **ВСЕГДА** оставь PR comment (даже если structural changes нет) — это
|
|
||||||
детерминированный marker для `check_docs` в pipeline-status.py. Без comment
|
|
||||||
pipeline блокируется на DOCS phase. Используй tool `post-docs-review` (НЕ
|
|
||||||
raw bash-вызов `gh`) — tool гарантирует heading `## Docs Review Summary` и
|
|
||||||
verdict-enum (zod), формат который парсит `check_docs`.
|
|
||||||
`post-docs-review({ pr_number: M, verdict: "APPROVE|FIXED|NO_CHANGES", body: "- Project map: ...\n- Handoff: ...\n- ADR: ..." })`.
|
|
||||||
Heading `## Docs Review Summary` — обязательно (regex `Docs Review`),
|
|
||||||
tool добавляет его автоматически — НЕ форматируй heading/verdict вручную.
|
|
||||||
Если tool вернул строку начинающуюся с `⚠️ ...failed` — СООБЩИ оркестратору
|
|
||||||
о сбое и STOP (не fallback на raw bash).
|
|
||||||
Если найдёшь баг вне scope текущей задачи — загрузи skill `bug-discovery` через `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. Сообщи оркестратору: "Created issue #N: ...".
|
Если найдёшь баг вне scope текущей задачи — загрузи skill `bug-discovery` через `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. Сообщи оркестратору: "Created issue #N: ...".
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -101,11 +67,11 @@ Review PR#M в текущем репо (pre-merge, режим docs).
|
||||||
```
|
```
|
||||||
Review PR#M в текущем репо.
|
Review PR#M в текущем репо.
|
||||||
1. `gh pr view M --json headRefName,body,title`.
|
1. `gh pr view M --json headRefName,body,title`.
|
||||||
2. `git diff origin/master...HEAD`.
|
2. `git diff origin/HEAD...HEAD`.
|
||||||
3. Load project skills: `find .opencode/skills/ -name "SKILL.md"`, грузи каждый
|
3. Load project skills: `find .opencode/skills/ -name "SKILL.md"`, грузи каждый
|
||||||
через `skill("<name>")`.
|
через `skill("<name>")`.
|
||||||
4. Проверь: code quality, architecture, error handling, security, testing,
|
4. Проверь: code quality, architecture, error handling, security, testing,
|
||||||
duplication, project-specific rules, PR hygiene, handoff/ADR (quick check).
|
duplication, project-specific rules, PR hygiene.
|
||||||
5. Оставь review через tool `post-review` (НЕ `gh pr review --approve` —
|
5. Оставь review через tool `post-review` (НЕ `gh pr review --approve` —
|
||||||
GitHub блокирует self-approve; НЕ raw bash-вызов `gh`) — tool гарантирует
|
GitHub блокирует self-approve; НЕ raw bash-вызов `gh`) — tool гарантирует
|
||||||
heading `## Code Review Summary` + verdict-enum которые парсит
|
heading `## Code Review Summary` + verdict-enum которые парсит
|
||||||
|
|
@ -144,11 +110,13 @@ Log: `gh run view <run-id> --log-failed` output:
|
||||||
(default `~/.local/share/opencode/opencode-memory`, override через `OPENCODE_MEMORY_DIR`;
|
(default `~/.local/share/opencode/opencode-memory`, override через `OPENCODE_MEMORY_DIR`;
|
||||||
`{host}/{org}/{repo}` вычисли через
|
`{host}/{org}/{repo}` вычисли через
|
||||||
`git remote get-url origin` — см. `memory-syncer.md:38`).
|
`git remote get-url origin` — см. `memory-syncer.md:38`).
|
||||||
1. Прочитай `docs/handoff/pr-M-*.md` и `docs/decisions/*-pr-M-*.md` с текущего
|
1. Прочитай PR body через `gh pr view M --json body,title` (+ `gh issue view`
|
||||||
состояния (уже смерженный default branch — checkout/pull НЕ нужны, запрещены
|
для контекста issue, если PR ссылается на issue). Текущее состояние — уже
|
||||||
permission set'ом memory-syncer'а).
|
смерженный default branch (checkout/pull НЕ нужны, запрещены permission
|
||||||
|
set'ом memory-syncer'а).
|
||||||
2. Найди durable gotchas (не статусы, не "сейчас делаем"). Паттерны, указатели,
|
2. Найди durable gotchas (не статусы, не "сейчас делаем"). Паттерны, указатели,
|
||||||
non-obvious API quirks.
|
non-obvious API quirks. Источник: PR body `## Watch out` (gotchas) +
|
||||||
|
`## Pending` (follow-ups) + diff (`git diff` текущего state vs parent).
|
||||||
3. Добавь записи формата `- [YYYY-MM-DD, PR#M] <summary>` в конец файла
|
3. Добавь записи формата `- [YYYY-MM-DD, PR#M] <summary>` в конец файла
|
||||||
(секция "Handoff digest").
|
(секция "Handoff digest").
|
||||||
4. Квитанция ВСЕГДА (даже если durable нет): `- [date, PR#M] — (нет durable-записей)`.
|
4. Квитанция ВСЕГДА (даже если durable нет): `- [date, PR#M] — (нет durable-записей)`.
|
||||||
|
|
@ -171,7 +139,7 @@ merge-pr({ pr_number: M })
|
||||||
Если вернулась `⚠️ merge-pr failed ...` → репорт пользователю, STOP (НЕ retry
|
Если вернулась `⚠️ merge-pr failed ...` → репорт пользователю, STOP (НЕ retry
|
||||||
через raw bash — это нарушит orchestrator-контракт). Если `PR #M merged
|
через raw bash — это нарушит orchestrator-контракт). Если `PR #M merged
|
||||||
successfully ...` → 1 строка прогресса и re-loop (`pipeline-status` покажет
|
successfully ...` → 1 строка прогресса и re-loop (`pipeline-status` покажет
|
||||||
`Status: COMPLETE` или перейдёт на MEMORY phase).
|
`Status: COMPLETE` или перейдёт на MEMORY phase — 6-я фаза).
|
||||||
|
|
||||||
## API Restrictions
|
## API Restrictions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,17 @@ truth для порядка и действий — `spec-status` tool. На в
|
||||||
|
|
||||||
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
|
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
|
||||||
|
|
||||||
- **backend**: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
|
- **backend**: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich — legacy), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
||||||
- **fullstack**: backend + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
|
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
|
||||||
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
||||||
- **cli**: Typer (default) / click / argparse, hatchling build
|
- **cli**: Typer (default) / click / argparse, hatchling build
|
||||||
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
||||||
- **worker**: Prefect flows + tasks, prefect.yaml, docker-compose worker profile
|
- **worker**: Prefect flows + tasks, prefect.yaml, docker-compose worker profile
|
||||||
|
|
||||||
|
### Миграционная заметка (stack.md)
|
||||||
|
|
||||||
|
Если существующий `docs/spec/stack.md` содержит `react` или `aerich` (устаревшие значения до issue #231), spec-status пометит Phase 2 как NOT_DONE (`STACK_REQUIRED` теперь требует `svelte`/`sveltekit` для fullstack и не требует `aerich`). Обнови stack.md: замени `react` → `svelte`/`sveltekit`, удали `aerich` (Tortoise 1.0+ имеет встроенные миграции `tortoise makemigrations`).
|
||||||
|
|
||||||
## 9 фаз
|
## 9 фаз
|
||||||
|
|
||||||
### Phase 0: DETECT (subagent, без вопроса юзеру)
|
### Phase 0: DETECT (subagent, без вопроса юзеру)
|
||||||
|
|
@ -61,7 +65,7 @@ Prompt template A (см. ниже).
|
||||||
```
|
```
|
||||||
Выбери тип проекта:
|
Выбери тип проекта:
|
||||||
[1] backend — FastAPI + Tortoise, REST API, без frontend
|
[1] backend — FastAPI + Tortoise, REST API, без frontend
|
||||||
[2] fullstack — backend + React 19/Vite dashboard (monorepo)
|
[2] fullstack — backend + SvelteKit/Svelte 5 + Tailwind v4 + shadcn-svelte dashboard (TypeScript, monorepo)
|
||||||
[3] mcp-server — MCP + REST сервер (Patchright/Playwright over CDP)
|
[3] mcp-server — MCP + REST сервер (Patchright/Playwright over CDP)
|
||||||
[4] cli — Python CLI tool (Typer)
|
[4] cli — Python CLI tool (Typer)
|
||||||
[5] bot — Telegram bot (aiogram 3)
|
[5] bot — Telegram bot (aiogram 3)
|
||||||
|
|
@ -84,7 +88,7 @@ backend:
|
||||||
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
|
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
|
||||||
|
|
||||||
fullstack:
|
fullstack:
|
||||||
- frontend: [1] React 19 (default) / [2] SvelteKit / [3] add later
|
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default, mobile-first: PWA + axe + Playwright mobile — silent) / [2] add later
|
||||||
- DB: (same as backend)
|
- DB: (same as backend)
|
||||||
- Auth: (same as backend)
|
- Auth: (same as backend)
|
||||||
|
|
||||||
|
|
@ -156,7 +160,7 @@ Prompt template F (см. ниже).
|
||||||
|
|
||||||
```
|
```
|
||||||
Дефолтный roadmap (можешь править):
|
Дефолтный roadmap (можешь править):
|
||||||
1. scaffolding — repo structure, CI, .gitignore, LICENSE (через repo-init skill)
|
1. scaffolding — repo structure, CI, .gitignore, LICENSE (через project-template skill init flow)
|
||||||
2. core: <module 1> — ...
|
2. core: <module 1> — ...
|
||||||
3. core: <module 2> — ...
|
3. core: <module 2> — ...
|
||||||
4. auth (если выбран auth в Phase 2)
|
4. auth (если выбран auth в Phase 2)
|
||||||
|
|
@ -231,8 +235,8 @@ Spec complete. Issues: #N1, #N2, ...
|
||||||
Тип проекта: <type> (из frontmatter meta.md).
|
Тип проекта: <type> (из frontmatter meta.md).
|
||||||
Default stack для типа (хардкод, добавить всегда):
|
Default stack для типа (хардкод, добавить всегда):
|
||||||
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
|
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
|
||||||
- backend: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
|
- backend: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
||||||
- fullstack: + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
|
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
|
||||||
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
||||||
- cli: Typer (default) / click / argparse, hatchling build
|
- cli: Typer (default) / click / argparse, hatchling build
|
||||||
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
||||||
|
|
@ -308,11 +312,12 @@ Default stack для типа (хардкод, добавить всегда):
|
||||||
## Контракты (ожидаемое поведение / API)
|
## Контракты (ожидаемое поведение / API)
|
||||||
## Инварианты (правила без исключений)
|
## Инварианты (правила без исключений)
|
||||||
## Граничные случаи (что при ошибках)
|
## Граничные случаи (что при ошибках)
|
||||||
|
## Влияние на связанные компоненты (зависящие файлы/оракулы/агенты/промпты/валидаторы; paired updates; «нет связанных компонентов» для тривиальных задач)
|
||||||
## Вне scope (что НЕ делаем)
|
## Вне scope (что НЕ делаем)
|
||||||
## Критерии приемки (как проверяем)
|
## Критерии приемки (как проверяем)
|
||||||
## Связанные ресурсы (Part of spec, ref к docs/spec/roadmap.md)
|
## Связанные ресурсы (Part of spec, ref к docs/spec/roadmap.md)
|
||||||
- Issue #1 (scaffolding) body ДОЛЖЕН включать:
|
- Issue #1 (scaffolding) body ДОЛЖЕН включать:
|
||||||
"Используй repo-init skill для: pyproject.toml, CI, .gitignore, LICENSE, dependabot, pre-commit. Структура — из ## Структура в docs/spec/modules.md."
|
"Используй project-template skill init flow для: cookiecutter по типу проекта (pyproject.toml, CI, .gitignore, LICENSE, dependabot, pre-commit). Структура — из ## Структура в docs/spec/modules.md."
|
||||||
- `create-issue({ title: "type(scope): description", body: "<body>", labels: ["enhancement", "from-spec"] })` tool (НЕ raw `gh issue create` — заблокирован deny)
|
- `create-issue({ title: "type(scope): description", body: "<body>", labels: ["enhancement", "from-spec"] })` tool (НЕ raw `gh issue create` — заблокирован deny)
|
||||||
4. Собери реальные номера issues из вывода tool.
|
4. Собери реальные номера issues из вывода tool.
|
||||||
5. Update docs/spec/roadmap.md: добавь реальные #N номера. Update docs/spec/meta.md: executed=true, phase=8.
|
5. Update docs/spec/roadmap.md: добавь реальные #N номера. Update docs/spec/meta.md: executed=true, phase=8.
|
||||||
|
|
|
||||||
8
.opencode/templates/backend/cookiecutter.json
Normal file
8
.opencode/templates/backend/cookiecutter.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"project_name": "my_project",
|
||||||
|
"project_type": "backend",
|
||||||
|
"description": "Project description",
|
||||||
|
"use_auth": ["no", "yes"],
|
||||||
|
"use_db": ["yes", "no"],
|
||||||
|
"python_version": "3.13"
|
||||||
|
}
|
||||||
64
.opencode/templates/backend/hooks/post_gen_project.py
Normal file
64
.opencode/templates/backend/hooks/post_gen_project.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Post-generation hook for the backend cookiecutter template.
|
||||||
|
|
||||||
|
Removes files that are conditional on the ``use_auth`` and ``use_db`` flags
|
||||||
|
so the rendered tree only contains the parts the user asked for.
|
||||||
|
|
||||||
|
- ``use_auth == "no"`` -> drop ``routes/auth.py``, ``services/auth_service.py``,
|
||||||
|
``models/user.py`` (hashed_password), and strip the auth dependency wiring.
|
||||||
|
- ``use_db == "no"`` -> drop ``db/``, ``migrations/``, ``connection.py``.
|
||||||
|
The DB dep (``tortoise-orm``/``asyncpg``) is conditionally rendered in
|
||||||
|
``pyproject.toml`` (jinja if-block on use_db), so no marker needs
|
||||||
|
to be written: the project-status oracle auto-detects db-projects from
|
||||||
|
deps (issue #274).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_DIR = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(path: str) -> None:
|
||||||
|
"""Remove a file or directory relative to the generated project root."""
|
||||||
|
p = PROJECT_DIR / path
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
elif p.exists():
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
use_auth = "{{ cookiecutter.use_auth }}"
|
||||||
|
use_db = "{{ cookiecutter.use_db }}"
|
||||||
|
pkg = "{{ cookiecutter.project_name }}"
|
||||||
|
|
||||||
|
if use_auth == "no":
|
||||||
|
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
||||||
|
_remove(f"src/{pkg}/services/auth_service.py")
|
||||||
|
_remove("tests/test_auth.py")
|
||||||
|
|
||||||
|
if use_db == "no":
|
||||||
|
# db is the root cause for the broken-conditional findings: files
|
||||||
|
# with unconditional ``from ...db.models.user import User`` must be
|
||||||
|
# stripped together with db/, otherwise the generated project
|
||||||
|
# fails to import (ImportError/NameError on startup).
|
||||||
|
_remove(f"src/{pkg}/db")
|
||||||
|
_remove("migrations")
|
||||||
|
_remove(f"src/{pkg}/services/user_service.py")
|
||||||
|
_remove(f"src/{pkg}/api/v1/routes/users.py")
|
||||||
|
_remove(f"src/{pkg}/api/v1/dependencies.py")
|
||||||
|
_remove(f"src/{pkg}/schemas/user.py")
|
||||||
|
_remove("tests/unit/test_user.py")
|
||||||
|
_remove("tests/unit/test_user_service.py")
|
||||||
|
_remove("tests/api/test_users.py")
|
||||||
|
if use_auth == "yes":
|
||||||
|
# auth_service imports User; strip it and its wiring too.
|
||||||
|
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
||||||
|
_remove(f"src/{pkg}/services/auth_service.py")
|
||||||
|
_remove("tests/test_auth.py")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
35
.opencode/templates/backend/hooks/pre_gen_project.py
Normal file
35
.opencode/templates/backend/hooks/pre_gen_project.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Pre-generation hook for the backend cookiecutter template.
|
||||||
|
|
||||||
|
Validates that ``project_name`` is a valid Python identifier so the
|
||||||
|
generated package dir + imports (``from <project_name>.X import Y``) do
|
||||||
|
not raise ``SyntaxError``. Hyphens, dots, spaces and leading digits are
|
||||||
|
rejected with a hint to use an underscore-separated name instead.
|
||||||
|
|
||||||
|
See issue #262: default ``my-project`` used to render as
|
||||||
|
``from my-project.config.settings import settings`` which is a
|
||||||
|
``SyntaxError`` (``-`` is not allowed in a Python identifier).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_VALID_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
project_name = "{{ cookiecutter.project_name }}"
|
||||||
|
if not _VALID_IDENTIFIER.fullmatch(project_name):
|
||||||
|
sys.exit(
|
||||||
|
f"Invalid project_name: {project_name!r}\n"
|
||||||
|
"project_name must be a valid Python identifier matching "
|
||||||
|
"^[a-z][a-z0-9_]*$ (lowercase, no hyphens/dots/spaces, "
|
||||||
|
"no leading digit).\n"
|
||||||
|
f"Use 'my_project' instead of 'my-project' (or 'my.project', "
|
||||||
|
"'my project')."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
.opencode/templates/backend/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
10
.opencode/templates/backend/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
42
.opencode/templates/backend/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
42
.opencode/templates/backend/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run mypy src tests
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run pytest
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv build
|
||||||
18
.opencode/templates/backend/{{cookiecutter.project_name}}/.gitignore
vendored
Normal file
18
.opencode/templates/backend/{{cookiecutter.project_name}}/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.5.0
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.10.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies: [pydantic-settings]
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-added-large-files
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ cookiecutter.python_version }}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) {% now 'utc', '%Y' %} slaid098
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Application
|
||||||
|
APP__ENVIRONMENT=dev
|
||||||
|
SERVER__HOST=0.0.0.0
|
||||||
|
SERVER__PORT=8000
|
||||||
|
|
||||||
|
# Database (nested via __ separator — pydantic-settings convention)
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
DATABASE__URL=sqlite://db.sqlite3
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
# Auth
|
||||||
|
JWT__SECRET=change-me-in-production
|
||||||
|
JWT__ALGORITHM=HS256
|
||||||
|
JWT__EXPIRE_MINUTES=60
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
# IP whitelist (no hardcoded production IPs — override in production)
|
||||||
|
IP_WHITELIST=["127.0.0.1", "::1"]
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""FastAPI application entry point for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.logger import setup_logging
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.db.connection import close_db, init_db
|
||||||
|
{% endif %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.router import api_router
|
||||||
|
|
||||||
|
_BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
{% if cookiecutter.use_db == "yes" %}setup_logging()
|
||||||
|
await init_db()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await close_db(){% else %}setup_logging()
|
||||||
|
yield{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
static_dir = _BASE_DIR / "static"
|
||||||
|
static_dir.mkdir(exist_ok=True)
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Tortoise migrations directory
|
||||||
|
|
||||||
|
This directory holds migration files generated by the built-in Tortoise
|
||||||
|
migrator. Run:
|
||||||
|
|
||||||
|
python -m tortoise.migrator makemigrations
|
||||||
|
|
||||||
|
Generated files land here and are committed to git.
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "{{ cookiecutter.project_name }}"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "{{ cookiecutter.description }}"
|
||||||
|
license = "MIT"
|
||||||
|
requires-python = ">={{ cookiecutter.python_version }}"
|
||||||
|
authors = [{ name = "slaid098" }]
|
||||||
|
keywords = []
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
"pydantic-settings",
|
||||||
|
"loguru",
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
"tortoise-orm",
|
||||||
|
"asyncpg",
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
"passlib[bcrypt]",
|
||||||
|
"pyjwt",
|
||||||
|
{% endif %}
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=5.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"pytest-timeout>=2.2",
|
||||||
|
"httpx",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"ruff>=0.5",
|
||||||
|
"pre-commit>=3.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
|
Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
|
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/{{cookiecutter.project_name}}"]
|
||||||
|
|
||||||
|
# ── Ruff ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py313"
|
||||||
|
line-length = 100
|
||||||
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", "W",
|
||||||
|
"F",
|
||||||
|
"I",
|
||||||
|
"B",
|
||||||
|
"UP",
|
||||||
|
"SIM",
|
||||||
|
"C90",
|
||||||
|
"PL",
|
||||||
|
"RUF",
|
||||||
|
"S",
|
||||||
|
"TRY",
|
||||||
|
"LOG",
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"S101",
|
||||||
|
"S311",
|
||||||
|
"RUF001",
|
||||||
|
"RUF002",
|
||||||
|
"RUF003",
|
||||||
|
"TRY003",
|
||||||
|
"PLR2004",
|
||||||
|
"S106",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.mccabe]
|
||||||
|
max-complexity = 10
|
||||||
|
|
||||||
|
[tool.ruff.lint.pylint]
|
||||||
|
max-args = 5
|
||||||
|
max-branches = 12
|
||||||
|
max-returns = 5
|
||||||
|
max-statements = 50
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"]
|
||||||
|
|
||||||
|
# ── mypy ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "{{ cookiecutter.python_version }}"
|
||||||
|
strict = true
|
||||||
|
explicit_package_bases = true
|
||||||
|
warn_return_any = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
|
||||||
|
# ── pytest ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
addopts = "--cov=src --cov-report=term-missing --timeout=120"
|
||||||
|
|
||||||
|
# ── coverage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── project-status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[tool.project-status]
|
||||||
|
route_line_limit = 50
|
||||||
|
min_test_count = 1
|
||||||
|
require_branch_protection = false
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""{{ cookiecutter.project_name }} package."""
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""API package for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.router import api_router # noqa: F401
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""API router aggregation for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.router import v1_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(v1_router, prefix="/v1")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""v1 API package."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.router import v1_router # noqa: F401
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Shared dependencies for v1 routes.
|
||||||
|
|
||||||
|
``check_ip_whitelist`` is always present; ``get_current_user`` is wired
|
||||||
|
only when ``use_auth == "yes``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
|
||||||
|
DEFAULT_WHITELIST = ["127.0.0.1", "::1"]
|
||||||
|
|
||||||
|
|
||||||
|
async def check_ip_whitelist(request: Request) -> None:
|
||||||
|
"""Reject requests from non-whitelisted IPs.
|
||||||
|
|
||||||
|
Defaults to ``["127.0.0.1", "::1"]`` (no hardcoded production IPs);
|
||||||
|
override via ``IP_WHITELIST`` in env.
|
||||||
|
"""
|
||||||
|
client = request.client.host if request.client else None
|
||||||
|
whitelist = settings.ip_whitelist or DEFAULT_WHITELIST
|
||||||
|
if client and client not in whitelist:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"IP {client} not allowed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
|
||||||
|
auth: AuthService = Depends(AuthService),
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the current user from the bearer token (JWT).
|
||||||
|
|
||||||
|
Returns the username/subject of the token. Only present when
|
||||||
|
``use_auth == "yes"``.
|
||||||
|
"""
|
||||||
|
return await auth.decode_access_token(credentials.credentials)
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Versioned router (v1) for {{ cookiecutter.project_name }}."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.dependencies import check_ip_whitelist
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import users
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import auth
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
v1_router = APIRouter(dependencies=[Depends(check_ip_whitelist)])
|
||||||
|
v1_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
|
{% else %}
|
||||||
|
v1_router = APIRouter()
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
v1_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Routes package for v1."""
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import users # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.api.v1.routes import auth # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Auth routes — /login, /register (only when use_auth=yes)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import Token, UserCreate, UserLogin, UserResponse
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=201)
|
||||||
|
async def register(payload: UserCreate) -> UserResponse:
|
||||||
|
"""Register a new user — returns the public profile."""
|
||||||
|
user = await AuthService.register(payload.username, payload.email, payload.password)
|
||||||
|
return UserResponse(id=user.id, username=user.username, email=user.email)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(payload: UserLogin) -> Token:
|
||||||
|
"""Login with username + password — returns a JWT."""
|
||||||
|
access_token = await AuthService.login(payload.username, payload.password)
|
||||||
|
return Token(access_token=access_token)
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""User routes — thin handlers (≤50 lines), delegate to services."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import UserResponse
|
||||||
|
from {{ cookiecutter.project_name }}.services.user_service import UserService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserResponse])
|
||||||
|
async def list_users() -> list[UserResponse]:
|
||||||
|
"""List users — thin handler, business logic lives in the service."""
|
||||||
|
users = await UserService.get_users()
|
||||||
|
return [UserResponse.model_validate(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(user_id: int) -> UserResponse:
|
||||||
|
"""Get a single user by id — thin handler."""
|
||||||
|
user = await UserService.get_user(user_id)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Configuration package — settings + logger."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings # noqa: F401
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Loguru logging setup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure loguru sink — remove default handler, add stdout."""
|
||||||
|
logger.remove()
|
||||||
|
logger.add(
|
||||||
|
sys.stdout,
|
||||||
|
level="INFO",
|
||||||
|
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level}</level> | {message}",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Application settings via pydantic-settings.
|
||||||
|
|
||||||
|
Nested fields use the ``__`` separator (pydantic-settings convention):
|
||||||
|
``DATABASE__URL`` -> ``settings.database.url``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Environment(StrEnum):
|
||||||
|
DEV = "dev"
|
||||||
|
STAGING = "staging"
|
||||||
|
PROD = "prod"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSettings(BaseSettings):
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
class DatabaseSettings(BaseSettings):
|
||||||
|
url: str = "sqlite://db.sqlite3"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
class JWTSettings(BaseSettings):
|
||||||
|
secret: str = "change-me-in-production"
|
||||||
|
algorithm: str = "HS256"
|
||||||
|
expire_minutes: int = 60
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
environment: Environment = Environment.DEV
|
||||||
|
server: ServerSettings = Field(default_factory=ServerSettings)
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
jwt: JWTSettings = Field(default_factory=JWTSettings)
|
||||||
|
{% endif %}
|
||||||
|
ip_whitelist: list[str] = Field(default_factory=lambda: ["127.0.0.1", "::1"])
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% if cookiecutter.use_db == "yes" %}"""DB package — Tortoise ORM connection + models."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.db.connection import close_db, init_db # noqa: F401
|
||||||
|
{% else %}"""DB package (disabled — use_db=no)."""
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Tortoise ORM connection setup.
|
||||||
|
|
||||||
|
Uses ``Path(__file__)``-relative paths (no CWD reliance) so the app is
|
||||||
|
portable regardless of the working directory it is launched from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tortoise import Tortoise
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
|
||||||
|
_MODELS_PATH = "{{ cookiecutter.project_name }}.db.models"
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
"""Initialize Tortoise with generate_schemas (built-in, NOT Aerich)."""
|
||||||
|
await Tortoise.init(
|
||||||
|
db_url=settings.database.url,
|
||||||
|
modules={"models": [_MODELS_PATH]},
|
||||||
|
)
|
||||||
|
await Tortoise.generate_schemas(safe=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
"""Close all Tortoise connections."""
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Tortoise ORM models."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User # noqa: F401
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""User model.
|
||||||
|
|
||||||
|
When ``use_auth == "yes"`` the ``hashed_password`` field is present;
|
||||||
|
otherwise the model holds only the public profile fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model):
|
||||||
|
id = fields.IntField(pk=True)
|
||||||
|
username = fields.CharField(max_length=64, unique=True)
|
||||||
|
email = fields.CharField(max_length=128, unique=True)
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
hashed_password = fields.CharField(max_length=255)
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "users"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.username
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Pydantic schemas."""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.base import MetaResponse, PaginatedResponse # noqa: F401
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.schemas.user import UserResponse # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Base schemas — shared response envelopes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class MetaResponse(BaseModel):
|
||||||
|
"""Standard meta block for API responses."""
|
||||||
|
|
||||||
|
total: int
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
|
"""Generic paginated envelope: ``{items, meta}``."""
|
||||||
|
|
||||||
|
items: list[T]
|
||||||
|
meta: MetaResponse
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""User schemas.
|
||||||
|
|
||||||
|
When ``use_auth == "yes"`` the auth-related schemas (``UserCreate``,
|
||||||
|
``UserLogin``, ``Token``) are added; ``UserResponse`` and ``UserInput``
|
||||||
|
are always present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserInput(BaseModel):
|
||||||
|
"""Input payload for creating a user."""
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=64)
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""Public user representation (never leaks the password)."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""Registration payload — username, email, plain password."""
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=64)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
"""Login payload — username + plain password."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""JWT response envelope."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Services package — business logic (Tortoise queries)."""
|
||||||
|
|
||||||
|
{% if cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.services.user_service import UserService # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService # noqa: F401
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Auth service — JWT issuance + verification (passlib[bcrypt] + pyjwt).
|
||||||
|
|
||||||
|
Only rendered when ``use_auth == "yes"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User
|
||||||
|
|
||||||
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""Stateless auth service — JWT + password hashing."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _pwd.hash(password)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return _pwd.verify(plain, hashed)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_access_token(username: str) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.jwt.expire_minutes,
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {"sub": username, "exp": expire}
|
||||||
|
return jwt.encode(payload, settings.jwt.secret, algorithm=settings.jwt.algorithm)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def register(username: str, email: str, password: str) -> User:
|
||||||
|
hashed = AuthService.hash_password(password)
|
||||||
|
return await User.create(
|
||||||
|
username=username, email=email, hashed_password=hashed,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def login(username: str, password: str) -> str:
|
||||||
|
user = await User.get_or_none(username=username)
|
||||||
|
if user is None or not AuthService.verify_password(password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid credentials",
|
||||||
|
)
|
||||||
|
return AuthService.create_access_token(username)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def decode_access_token(token: str) -> str:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token, settings.jwt.secret, algorithms=[settings.jwt.algorithm],
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
) from exc
|
||||||
|
return str(payload.get("sub", ""))
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""User service — business logic (Tortoise queries).
|
||||||
|
|
||||||
|
Fix from slaid098/templates: ``get_users`` returns a plain ``list`` of
|
||||||
|
User instances (the old implementation returned a tuple while every caller
|
||||||
|
expected a list).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
"""Stateless user service — all methods are async classmethods."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_users() -> list[User]:
|
||||||
|
"""Return all users as a list (NOT a tuple — bug fixed)."""
|
||||||
|
return list(await User.all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user(user_id: int) -> User:
|
||||||
|
"""Return a single user by id or raise 404."""
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"User {user_id} not found",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_user(username: str, email: str) -> User:
|
||||||
|
"""Create a new user."""
|
||||||
|
return await User.create(username=username, email=email)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Utils package.
|
||||||
|
|
||||||
|
NOTE: ProjectMetadata lives ONLY in ``metadata.py`` — no duplication in
|
||||||
|
``__init__.py`` (fix from slaid098/templates).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.utils.metadata import ProjectMetadata # noqa: F401
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Project metadata read from ``pyproject.toml`` (single source of truth).
|
||||||
|
|
||||||
|
No duplication — callers import from here, never re-declare the metadata.
|
||||||
|
Uses ``Path(__file__)`` to locate ``pyproject.toml`` regardless of CWD.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_PYPROJECT = Path(__file__).resolve().parents[3] / "pyproject.toml"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectMetadata:
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_metadata() -> ProjectMetadata:
|
||||||
|
"""Load metadata from ``pyproject.toml`` (single source of truth)."""
|
||||||
|
with _PYPROJECT.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
project = data.get("project", {})
|
||||||
|
return ProjectMetadata(
|
||||||
|
name=str(project.get("name", "")),
|
||||||
|
version=str(project.get("version", "")),
|
||||||
|
description=str(project.get("description", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
metadata = load_metadata()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests package."""
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""API tests package."""
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""API tests for user routes (thin handlers → services)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_list_users(client) -> None:
|
||||||
|
"""GET /api/v1/users returns a list of users."""
|
||||||
|
response = await client.get("/api/v1/users")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_ip_whitelist_blocks(client) -> None:
|
||||||
|
"""Non-whitelisted IPs must get 403."""
|
||||||
|
response = await client.get("/api/v1/users", headers={"X-Forwarded-For": "10.0.0.1"})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Pytest configuration — fixtures shared across all tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_settings(monkeypatch) -> None:
|
||||||
|
"""Override settings with safe test defaults (no real DB/SMTP/etc)."""
|
||||||
|
monkeypatch.setattr(settings, "environment", "dev")
|
||||||
|
monkeypatch.setattr(settings, "ip_whitelist", ["127.0.0.1", "::1"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client() -> AsyncIterator[AsyncClient]:
|
||||||
|
"""HTTPX AsyncClient bound to the FastAPI app (no network)."""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %}
|
||||||
|
@pytest.fixture
|
||||||
|
async def create_user() -> Any:
|
||||||
|
"""Factory: create a user in the test DB."""
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
async def _create(username: str = "tester", password: str = "password123") -> User:
|
||||||
|
hashed = AuthService.hash_password(password)
|
||||||
|
return await User.create(username=username, email=f"{username}@test.local", hashed_password=hashed)
|
||||||
|
|
||||||
|
return _create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_client(create_user) -> AsyncIterator[AsyncClient]:
|
||||||
|
"""HTTPX client with a valid bearer token pre-set."""
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
await create_user()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
resp = await ac.post("/api/v1/auth/login", json={"username": "tester", "password": "password123"})
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
ac.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
yield ac
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Integration tests package."""
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Integration tests — skip by default unless real credentials are set.
|
||||||
|
|
||||||
|
``pytestmark`` = [pytest.mark.integration, skipif no creds] — these only
|
||||||
|
run when an explicit env var is set (e.g. ``RUN_INTEGRATION=1``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.integration,
|
||||||
|
pytest.mark.skipif(
|
||||||
|
not os.getenv("RUN_INTEGRATION"),
|
||||||
|
reason="no real external credentials — set RUN_INTEGRATION=1 to enable",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_external_placeholder() -> None:
|
||||||
|
"""Placeholder — replace with a real external service call."""
|
||||||
|
assert True
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Auth flow tests — /register, /login (only when use_auth=yes)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_register(client) -> None:
|
||||||
|
"""POST /api/v1/auth/register creates a user."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"username": "newuser", "email": "new@test.local", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_login_returns_token(client) -> None:
|
||||||
|
"""POST /api/v1/auth/login returns a JWT."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "tester", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "access_token" in response.json()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Unit tests package."""
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Unit tests for the User model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
{% if cookiecutter.use_auth == "yes" %}
|
||||||
|
from {{ cookiecutter.project_name }}.services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_is_not_plain() -> None:
|
||||||
|
"""hash_password must not store the plain text."""
|
||||||
|
hashed = AuthService.hash_password("mypassword")
|
||||||
|
assert hashed != "mypassword"
|
||||||
|
assert hashed.startswith("$2") # bcrypt prefix
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_roundtrip() -> None:
|
||||||
|
"""verify_password must accept the correct password."""
|
||||||
|
hashed = AuthService.hash_password("mypassword")
|
||||||
|
assert AuthService.verify_password("mypassword", hashed) is True
|
||||||
|
assert AuthService.verify_password("wrong", hashed) is False
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_model_table_name() -> None:
|
||||||
|
"""User model must use the 'users' table."""
|
||||||
|
from {{ cookiecutter.project_name }}.db.models.user import User
|
||||||
|
|
||||||
|
assert User._meta.db_table == "users"
|
||||||
|
{% endif %}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Unit tests for UserService (business logic, not HTTP)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from {{ cookiecutter.project_name }}.services.user_service import UserService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)")
|
||||||
|
async def test_get_users_returns_list() -> None:
|
||||||
|
"""get_users must return a list (NOT a tuple — bug fixed)."""
|
||||||
|
users = await UserService.get_users()
|
||||||
|
assert isinstance(users, list)
|
||||||
8
.opencode/templates/cli/cookiecutter.json
Normal file
8
.opencode/templates/cli/cookiecutter.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"project_name": "my_cli",
|
||||||
|
"project_type": "cli",
|
||||||
|
"description": "CLI tool description",
|
||||||
|
"use_auth": ["no", "yes"],
|
||||||
|
"use_db": ["yes", "no"],
|
||||||
|
"python_version": "3.13"
|
||||||
|
}
|
||||||
31
.opencode/templates/cli/hooks/post_gen_project.py
Normal file
31
.opencode/templates/cli/hooks/post_gen_project.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Post-generation hook for the cli cookiecutter template.
|
||||||
|
|
||||||
|
The cli template is unconditional (no use_auth/use_db flags affect it),
|
||||||
|
but the hook is kept for parity with backend/fullstack so the same
|
||||||
|
contract applies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_DIR = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(path: str) -> None:
|
||||||
|
"""Remove a file or directory relative to the generated project root."""
|
||||||
|
p = PROJECT_DIR / path
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p, ignore_errors=True)
|
||||||
|
elif p.exists():
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""No conditional files for the cli template yet — placeholder."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
34
.opencode/templates/cli/hooks/pre_gen_project.py
Normal file
34
.opencode/templates/cli/hooks/pre_gen_project.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Pre-generation hook for the cli cookiecutter template.
|
||||||
|
|
||||||
|
Validates that ``project_name`` is a valid Python identifier so the
|
||||||
|
generated package dir + imports (``from <project_name>.X import Y``) do
|
||||||
|
not raise ``SyntaxError``. Hyphens, dots, spaces and leading digits are
|
||||||
|
rejected with a hint to use an underscore-separated name instead.
|
||||||
|
|
||||||
|
See issue #262: default ``my-cli`` used to render as
|
||||||
|
``from my-cli.core import app`` which is a ``SyntaxError`` (``-`` is not
|
||||||
|
allowed in a Python identifier).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_VALID_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
project_name = "{{ cookiecutter.project_name }}"
|
||||||
|
if not _VALID_IDENTIFIER.fullmatch(project_name):
|
||||||
|
sys.exit(
|
||||||
|
f"Invalid project_name: {project_name!r}\n"
|
||||||
|
"project_name must be a valid Python identifier matching "
|
||||||
|
"^[a-z][a-z0-9_]*$ (lowercase, no hyphens/dots/spaces, "
|
||||||
|
"no leading digit).\n"
|
||||||
|
f"Use 'my_cli' instead of 'my-cli' (or 'my.cli', 'my cli')."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
.opencode/templates/cli/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
10
.opencode/templates/cli/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
42
.opencode/templates/cli/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
42
.opencode/templates/cli/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run mypy src tests
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv run pytest
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- run: uv build
|
||||||
16
.opencode/templates/cli/{{cookiecutter.project_name}}/.gitignore
vendored
Normal file
16
.opencode/templates/cli/{{cookiecutter.project_name}}/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue