Compare commits
41 commits
chore/clea
...
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 |
268 changed files with 13417 additions and 1150 deletions
|
|
@ -18,6 +18,10 @@ OPENCODE_SERVER_PASSWORD=your-opencode-server-password
|
|||
# GitHub
|
||||
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_API_KEY=your-context7-api-key-here
|
||||
|
||||
|
|
|
|||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
|
|
@ -35,10 +35,6 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- 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 run ruff check src/ tests/ .opencode/scripts/
|
||||
- run: uv run ruff format --check src/ tests/ .opencode/scripts/
|
||||
|
|
@ -50,10 +46,6 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- 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 run mypy src/
|
||||
|
||||
|
|
@ -65,23 +57,15 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python: ["3.12", "3.13", "3.14"]
|
||||
python: ["3.13"]
|
||||
steps:
|
||||
- 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
|
||||
working-directory: .opencode
|
||||
- run: npm ci
|
||||
working-directory: .opencode/draw-image
|
||||
- run: npm test
|
||||
working-directory: .opencode/draw-image
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- run: uv sync --extra dev --python ${{ matrix.python }}
|
||||
- run: uv run --python ${{ matrix.python }} pytest
|
||||
|
||||
|
|
@ -92,9 +76,5 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- 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 run xenon --max-absolute B --max-modules A --max-average A src/
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
### Дедуп перед записью (ОБЯЗАТЕЛЬНО)
|
||||
### Выбор файла для записи (ОБЯЗАТЕЛЬНО перед записью)
|
||||
|
||||
Перед добавлением записи — прочитай существующий файл. Если похожая запись уже есть (та же гоча/паттерн/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 → компрессировать:
|
||||
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
|
||||
### Дедуп across files (ОБЯЗАТЕЛЬНО перед записью)
|
||||
|
||||
Критерий выкидывания: «поможет ли это в следующий раз когда я полезу в этот код?» Нет → выкидывай.
|
||||
Перед добавлением новой записи — ищи дубликат по **всем** `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
|
||||
|
||||
|
|
@ -108,7 +115,7 @@ 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.
|
||||
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. 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.
|
||||
6. If memory file doesn't exist — create it with proper frontmatter (title, tags, summary, created, updated, importance).
|
||||
|
|
|
|||
|
|
@ -109,10 +109,18 @@ You are a global code reviewer. Your job: review PRs against project skills and
|
|||
|
||||
## 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.
|
||||
An incomplete review with verdict NEEDS_DISCUSSION is better than an infinite investigation.
|
||||
Do NOT repeatedly verify references in agent .md files — read once, assess, move on.
|
||||
An incomplete review with verdict NEEDS_DISCUSSION is better than an infinite
|
||||
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
|
||||
|
||||
|
|
@ -209,6 +217,44 @@ Examples of project-specific rules:
|
|||
осмысленно (не пустые плейсхолдеры, `—` допустим для Watch out/Pending если
|
||||
нет контента). Если PR body неполный → REQUEST_CHANGES.
|
||||
|
||||
## 10. Cross-file impact analysis
|
||||
|
||||
Для каждого изменённого файла в PR:
|
||||
- `rg` по репо — кто читает/пишет тот же ресурс (файл-путь pattern, формат,
|
||||
литерал, env var, frontmatter key, comment format).
|
||||
- Категории связей:
|
||||
- oracle-скрипты (`pipeline-status.py`, `spec-status.py`,
|
||||
`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
|
||||
|
||||
After reviewing, leave a GitHub PR comment using the `post-review` tool. The tool auto-generates the `## Code 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.
|
||||
|
|
@ -258,6 +304,48 @@ Body format (without heading — tool adds `## Code Review Summary` and `### Ver
|
|||
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.
|
||||
After this call, you MUST respond with your review text only. Do NOT call any more tools.
|
||||
|
||||
|
|
@ -309,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).
|
||||
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
|
||||
|
||||
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` сам.
|
||||
|
|
@ -38,7 +38,7 @@ Style is NEVER asked of the user and NEVER overridden — only content questions
|
|||
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. Можно использовать `create-readme validate` или прямую проверку; если `create-readme validate` падает (README без delimiter tags) — предупреди, но кавер всё равно остаётся отрендеренным (кавер ≠ README).
|
||||
7. **Validate README** — проверь, что в `README.md` есть ``. Если нет — добавь ссылку сразу после заголовка H1 (прямая проверка; опционально — `project-status.py check_readme`). Если `check_readme` падает (README без delimiter tags) — предупреди, но кавер всё равно остаётся отрендеренным (кавер ≠ README).
|
||||
8. **Done** — сообщи путь к готовому PNG.
|
||||
|
||||
## Extension mechanism (НЕ хардкод брендов)
|
||||
|
|
@ -56,6 +56,6 @@ Style is NEVER asked of the user and NEVER overridden — only content questions
|
|||
- **Пользователь хочет не-Lime кавер** → отказ: «команда /cover поддерживает только Lime-стиль. Кастомизация вне scope.»
|
||||
- **Существующий `assets/cover.png`** → спроси «перезаписать?» перед рендером.
|
||||
- **Lucide-иконка не найдена** → ошибка со списком похожих иконок (fuzzy match по имени в `icons/lucide/`).
|
||||
- **`create-readme validate` падает** (README без delimiter tags) → предупреди, но кавер рендерится.
|
||||
- **`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
|
||||
---
|
||||
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.
|
||||
|
|
@ -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": {
|
||||
|
|
@ -189,6 +236,11 @@
|
|||
"python3 */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",
|
||||
"git branch -D *": "ask",
|
||||
"git branch -d *": "ask",
|
||||
|
|
@ -276,7 +328,26 @@
|
|||
"git commit *": "deny",
|
||||
"gh pr create *": "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": {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import re
|
|||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
|
@ -85,10 +87,135 @@ class PhaseResult:
|
|||
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]:
|
||||
"""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
|
||||
"""Run a command, return (returncode, stdout, 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)
|
||||
|
|
@ -173,13 +300,23 @@ def parse_remote_url(url: str) -> tuple[str, str, str]:
|
|||
raise ValueError(f"Cannot parse remote URL: {url}")
|
||||
|
||||
|
||||
def get_memory_file_path() -> Path:
|
||||
"""Derive memory file path from ``git remote get-url origin``."""
|
||||
def _resolve_memory_base() -> tuple[Path, str]:
|
||||
"""Derive (memory_dir, repo_name) from ``git remote get-url origin``."""
|
||||
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Cannot get git remote URL: {err.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
|
||||
|
|
@ -408,7 +545,7 @@ def _extract_comment_bodies(json_str: str) -> list[str]:
|
|||
def check_review(pr_number: int) -> PhaseResult:
|
||||
"""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 —
|
||||
if reviewer changed from APPROVE to REQUEST_CHANGES, NOT_DONE.
|
||||
"""
|
||||
|
|
@ -458,24 +595,24 @@ def check_merge(pr_number: int) -> PhaseResult:
|
|||
def check_memory(pr_number: int) -> PhaseResult:
|
||||
"""Phase 7: MEMORY — PR#N distilled into memory file."""
|
||||
try:
|
||||
memory_file = get_memory_file_path()
|
||||
files = get_memory_files()
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
return PhaseResult(PhaseStatus.NOT_DONE, str(exc))
|
||||
|
||||
if not memory_file.exists():
|
||||
if not files:
|
||||
return PhaseResult(
|
||||
PhaseStatus.NOT_DONE,
|
||||
f"memory file не существует: {memory_file.name}",
|
||||
"memory files не найдены",
|
||||
)
|
||||
|
||||
content = memory_file.read_text()
|
||||
pattern = f"PR#{pr_number}"
|
||||
if pattern in content:
|
||||
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {memory_file.name}")
|
||||
for f in files:
|
||||
if pattern in f.read_text():
|
||||
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {f.name}")
|
||||
|
||||
return PhaseResult(
|
||||
PhaseStatus.NOT_DONE,
|
||||
f"{pattern} не найден в {memory_file.name}",
|
||||
f"{pattern} не найден в {len(files)} файл(ах)",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
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"],
|
||||
}
|
||||
|
|
@ -37,13 +37,23 @@ Nine phases:
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
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:
|
||||
"""Resolve repo root via git (cwd-aware), fallback to script location."""
|
||||
|
|
@ -67,16 +77,10 @@ PHASE_FILES: dict[int, str] = {
|
|||
6: "roadmap.md",
|
||||
}
|
||||
|
||||
VALID_TYPES = {"backend", "fullstack", "mcp-server", "cli", "bot", "worker"}
|
||||
|
||||
STACK_REQUIRED: dict[str, list[str]] = {
|
||||
"backend": ["fastapi", "tortoise", "uv", "pytest", "ruff", "mypy"],
|
||||
"fullstack": ["fastapi", "tortoise", "react", "vite", "biome", "uv"],
|
||||
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
|
||||
"cli": ["typer", "uv", "hatchling"],
|
||||
"bot": ["aiogram", "fastapi", "uv"],
|
||||
"worker": ["prefect", "uv"],
|
||||
}
|
||||
# Re-exported from project_contract.py for backward compatibility
|
||||
# (tests use ``ss.VALID_TYPES`` / ``ss.STACK_REQUIRED``).
|
||||
VALID_TYPES = project_contract.VALID_TYPES
|
||||
STACK_REQUIRED = project_contract.STACK_REQUIRED
|
||||
|
||||
PHASE_NAMES = [
|
||||
"DETECT",
|
||||
|
|
@ -90,7 +94,7 @@ PHASE_NAMES = [
|
|||
"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)
|
||||
|
||||
|
||||
|
|
@ -110,8 +114,47 @@ class PhaseResult:
|
|||
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]:
|
||||
"""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)
|
||||
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]:
|
||||
"""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)
|
||||
if not match:
|
||||
return {}
|
||||
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]]:
|
||||
"""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():
|
||||
return "", {}
|
||||
content = META_FILE.read_text()
|
||||
content = META_FILE.read_text(encoding="utf-8-sig")
|
||||
return content, parse_frontmatter(content)
|
||||
|
||||
|
||||
|
|
@ -229,8 +284,13 @@ def check_stack() -> PhaseResult:
|
|||
return PhaseResult(PhaseStatus.NOT_DONE, "docs/spec/stack.md не заполнен")
|
||||
required = STACK_REQUIRED[ptype]
|
||||
stack_body = stack_file.read_text()
|
||||
stack_lower = stack_body.lower()
|
||||
missing = [item for item in required if item not in stack_lower]
|
||||
# Word-boundary regex: ``uv`` does NOT match ``uvicorn``, ``tailwind``
|
||||
# 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:
|
||||
return PhaseResult(
|
||||
PhaseStatus.NOT_DONE,
|
||||
|
|
@ -260,7 +320,7 @@ def check_modules() -> PhaseResult:
|
|||
def check_db_schema() -> PhaseResult:
|
||||
"""Phase 4: DB_SCHEMA — no_db: true OR db-schema.md filled."""
|
||||
_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 не нужна)")
|
||||
db_file = SPEC_DIR / PHASE_FILES[4]
|
||||
if not file_filled(db_file):
|
||||
|
|
@ -304,7 +364,7 @@ def check_confirm() -> PhaseResult:
|
|||
"""Phase 7: CONFIRM — confirmed: true in meta.md frontmatter."""
|
||||
_content, fm = read_meta()
|
||||
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.DONE, "spec подтверждён юзером")
|
||||
|
||||
|
|
@ -336,7 +396,7 @@ def check_execute() -> PhaseResult:
|
|||
"""Phase 8: EXECUTE — executed: true + issues created (gh view --repo)."""
|
||||
_content, fm = read_meta()
|
||||
val = fm.get("executed", "").strip().lower()
|
||||
if val not in {"true", '"true"'}:
|
||||
if val != "true":
|
||||
return PhaseResult(PhaseStatus.NOT_DONE, "executed: true отсутствует в frontmatter")
|
||||
roadmap_file = SPEC_DIR / PHASE_FILES[6]
|
||||
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
|
||||
├── python-development/SKILL.md
|
||||
├── release/SKILL.md
|
||||
├── repo-init/SKILL.md
|
||||
├── project-template/SKILL.md
|
||||
├── run-pipeline/SKILL.md
|
||||
├── run-tests/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.
|
||||
2. Create a GitHub issue via `create-issue` tool (NOT raw `gh issue create`).
|
||||
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.
|
||||
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**: код без комментариев
|
||||
- Этот 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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
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
|
||||
|
|
@ -21,6 +21,21 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
|||
|
||||
Юзер описывает фичу. Агент читает 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
|
||||
|
||||
Агент задаёт вопросы списком — НЕ гадает. Пример:
|
||||
|
|
@ -30,10 +45,11 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
|||
1. Контракты: какой формат запроса/ответа? Какие коды ошибок?
|
||||
2. Инварианты: какие лимиты? Какой TTL? Какая модель/библиотека?
|
||||
3. Граничные случаи: что если внешний сервис недоступен? Что если данных нет?
|
||||
4. Вне scope: что точно НЕ делаем в этой итерации?
|
||||
4. Влияние на связанные компоненты: какие детерминированные связи (oracle-скрипты, валидаторы, парсеры, промпты-агенты) зависят от этого изменения? Что может сломаться если поменять X? Нужны ли paired updates в других файлах? (explore subagent уже нашёл candidates на шаге 1.5 — юзер подтверждает/дополняет)
|
||||
5. Вне scope: что точно НЕ делаем в этой итерации?
|
||||
```
|
||||
|
||||
Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 7 секций не заполнены.
|
||||
Юзер отвечает. Если ответ неполный — агент уточняет. Q&A продолжается пока все 8 секций не заполнены.
|
||||
|
||||
### 3. План
|
||||
|
||||
|
|
@ -56,6 +72,9 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
|||
## Граничные случаи
|
||||
[Что при ошибках: невалидный вход, отказ сервиса, превышение лимита]
|
||||
|
||||
## Влияние на связанные компоненты
|
||||
[Файлы/оракулы/агенты/промпты/валидаторы, которые зависят от изменения; нужен ли paired update. Если нет — явно «нет связанных компонентов»]
|
||||
|
||||
## Вне scope
|
||||
[Что НЕ делаем]
|
||||
|
||||
|
|
@ -68,7 +87,7 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
|||
|
||||
После готовности плана:
|
||||
- Агент: "План готов. Скажи 'создай issue' чтобы создать issue, потом запусти /run-pipeline."
|
||||
- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (7 секций) → issue создан
|
||||
- Юзер: "создай issue" → загружается `issue` скилл → `create-issue` tool (8 секций) → issue создан
|
||||
- Юзер: `/run-pipeline` → реализация
|
||||
|
||||
## Правила
|
||||
|
|
@ -83,9 +102,9 @@ description: Lightweight SDD-style Q&A skill for feature planning. Guides the ag
|
|||
- **Не создавай файлы** — план живёт в чате, потом в issue
|
||||
- **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, форматы, коды ошибок |
|
||||
| 4 | `## Инварианты` | Правила без исключений: лимиты, ограничения, технологии |
|
||||
| 5 | `## Граничные случаи` | Что при ошибках: edge cases, отказы сервисов |
|
||||
| 6 | `## Вне scope` | Что НЕ делаем в этой итерации |
|
||||
| 7 | `## Критерии приемки` | Проверяемые сценарии: "X → видит Y" |
|
||||
| 6 | `## Влияние на связанные компоненты` | Файлы/оракулы/агенты/промпты/валидаторы, зависящие от изменения; paired updates; «нет связанных компонентов» для тривиальных фич |
|
||||
| 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).
|
||||
```
|
||||
|
|
|
|||
|
|
@ -17,65 +17,3 @@ description: Use when you need to view or update the current project folder/file
|
|||
1. Запусти указанную команду в терминале. Она выведет дерево каталогов и список файлов с их размерами прямо в stdout.
|
||||
2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов.
|
||||
3. Не сохраняй вывод в файлы на диск — читай его напрямую из вывода терминала.
|
||||
|
||||
## 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
|
||||
[Что НЕ делаем в этой итерации]
|
||||
|
||||
|
|
@ -67,8 +70,8 @@ Issue создаёт **subagent** (general type), а не основной аг
|
|||
**Subagent (полная ответственность):**
|
||||
1. Загрузи навык `issue`
|
||||
2. Собери контекст — прочитай файлы из intent summary, пойми задачу, оцени объём (правило дробления ниже)
|
||||
3. Составь self-contained body по шаблону (Контекст → Задача → Контракты → Инварианты → Граничные случаи → Вне scope → Критерии приемки)
|
||||
4. Запусти `create-issue({ title: "...", body: "...", labels: ["..."] })` tool (НЕ raw `gh issue create` — заблокирован deny; tool валидирует conventional title format и headings `## Контекст`/`## Задача`/`## Контракты`/`## Инварианты`/`## Граничные случаи`/`## Вне scope`/`## Критерии приемки`)
|
||||
3. Составь self-contained body по шаблону (Контекст → Задача → Контракты → Инварианты → Граничные случаи → Влияние на связанные компоненты → Вне scope → Критерии приемки)
|
||||
4. Запусти `create-issue({ title: "...", body: "...", labels: ["..."] })` tool (НЕ raw `gh issue create` — заблокирован deny; tool валидирует conventional title format и headings `## Контекст`/`## Задача`/`## Контракты`/`## Инварианты`/`## Граничные случаи`/`## Влияние на связанные компоненты`/`## Вне scope`/`## Критерии приемки`)
|
||||
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").
|
||||
|
|
@ -101,6 +104,10 @@ Main agent НЕ пишет body и НЕ запускает `create-issue` — в
|
|||
- Кеш содержит устаревший формат → invalidate, пересчитать
|
||||
- Конкурентные запросы на одно видео → первый пишет в кеш, последующие берут из кеша
|
||||
|
||||
## Влияние на связанные компоненты
|
||||
- AnalysisController зависит от AnalysisService — без изменений (API сохранён)
|
||||
- «Нет связанных компонентов» для тривиальных задач
|
||||
|
||||
## Вне scope
|
||||
- ❌ Кеширование субтитров (отдельная задача)
|
||||
- ❌ Инвалидация по времени просмотра видео
|
||||
|
|
@ -132,9 +139,10 @@ create-issue({ title: "type(scope): description", body: "...", labels: ["<label>
|
|||
|
||||
Tool валидирует: title соответствует conventional format (type(scope): desc,
|
||||
≤80 chars, English), body содержит `## Контекст`, `## Задача`, `## Контракты`,
|
||||
`## Инварианты`, `## Граничные случаи`, `## Вне scope`, `## Критерии приемки`
|
||||
headings и на русском (Cyrillic обязательна). При ошибке валидации
|
||||
tool возвращает ошибку и НЕ вызывает gh — почини формат и повтори.
|
||||
`## Инварианты`, `## Граничные случаи`, `## Влияние на связанные компоненты`,
|
||||
`## Вне scope`, `## Критерии приемки` headings и на русском (Cyrillic
|
||||
обязательна). При ошибке валидации tool возвращает ошибку и НЕ вызывает gh —
|
||||
почини формат и повтори.
|
||||
|
||||
Label выбирай по типу задачи (совпадает с commit `type`):
|
||||
- `enhancement` — новая функциональность (`feat`)
|
||||
|
|
|
|||
|
|
@ -106,9 +106,16 @@ Default: `/root/.local/share/opencode/opencode-memory` (переопределя
|
|||
|
||||
Перед добавлением записи — прочитай существующий файл. Если факт уже записан — обнови запись (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`) — генерирует
|
||||
стандартизированный двуязычный README с нуля.
|
||||
- **Проверка существующего README** → `create-readme` (mode: `validate`) —
|
||||
проверяет, что структура соответствует стандарту витрины.
|
||||
- **После ручных правок README** → всегда `validate`. Любая правка руками
|
||||
- **Проверка существующего README** → `.opencode/scripts/project-status.py`
|
||||
(`check_readme`) — проверяет, что структура соответствует стандарту витрины.
|
||||
- **После ручных правок README** → всегда проверка через `check_readme`. Любая правка руками
|
||||
агента (через Edit/Write) может нарушить разделители — после правок
|
||||
обязательна валидация.
|
||||
обязательна проверка.
|
||||
|
||||
Не генерируй README вручную через Write — структура критична для парсинга
|
||||
витриной. Только через тулзу `create-readme`.
|
||||
|
|
@ -44,9 +44,10 @@ Support block, Quick Start, language switcher). Скилл даёт контек
|
|||
`"./assets/cover.png"` — можно не передавать.
|
||||
3. Ручные правки если нужно (агент редактирует файл напрямую через Edit) —
|
||||
например, расширить `custom_sections`, поправить формулировки.
|
||||
4. `create-readme` (mode: `validate`) → проверяет, что структура не нарушена
|
||||
(теперь в т.ч. наличие `assets/cover.png` reference).
|
||||
5. Если `validate` fails → фикс нарушения → re-`validate`. Цикл пока не
|
||||
4. Проверка через `.opencode/scripts/project-status.py` (`check_readme`) →
|
||||
структура соответствует стандарту витрины (в т.ч. наличие
|
||||
`assets/cover.png` reference).
|
||||
5. Если проверка fails → фикс нарушения → повторная проверка. Цикл пока не
|
||||
пройдёт.
|
||||
|
||||
Локальный режим (по умолчанию): тулза пишет в `file_path` (default
|
||||
|
|
@ -95,7 +96,7 @@ README ссылается именно на этот путь через `**
|
||||
👉 **[slaid098.dev/contacts](https://slaid098.dev/contacts)**
|
||||
```
|
||||
|
||||
`validate` проверяет: наличие всех 6 пар EN/RU разделителей (tagline + summary +
|
||||
features), непустой контент между ними, H1 title prefix `# 🚀 `, **cover image
|
||||
`check_readme` (project-status.py) проверяет: наличие всех 6 пар EN/RU
|
||||
разделителей (tagline + summary + features), непустой контент между ними, H1
|
||||
title prefix `# 🚀 `, **cover image
|
||||
reference `assets/cover.png`** (substring-чек, без проверки существования
|
||||
файла), ссылку
|
||||
`slaid098.dev/support`, секции Quick Start (EN) и Быстрый старт (RU), language
|
||||
`slaid098.dev/contacts`, секции Quick Start (EN) и Быстрый старт (RU), language
|
||||
switcher `[English]` / `[Русский]`, заголовок `## 🇷🇺 Русский` (не "Русская
|
||||
версия"), anchor `[Русский](#-русский)` (не `#-русская-версия`). Флагирует
|
||||
ручной заголовок `## License` / `## LICENSE` / `## Лицензия` как ERROR —
|
||||
ручной заголовок `## License` / `## LICENSE` / `## Лицензия` как FAIL —
|
||||
дубликат GitHub sidebar (GitHub рендерит license из LICENSE-файла). Шаги
|
||||
`quick_start_steps_*` не влияют на валидацию — они рендерятся вне delimiter-пар
|
||||
(summary/features).
|
||||
|
||||
## 6. Независимость от repo-init
|
||||
## 6. Независимость от project-template
|
||||
|
||||
- Скилл `repo-init` создаёт **пустой** `README.md` как часть инициализации
|
||||
репо.
|
||||
- `repo-readme` (через тулзу `create-readme`) **наполняет** его
|
||||
стандартизированным контентом.
|
||||
- Может применяться к существующим репо без `repo-init` — тулза перезапишет
|
||||
`README.md` (локально) или обновит через GitHub API (с SHA).
|
||||
- Скилл `project-template` (init flow) создаёт проект через cookiecutter —
|
||||
шаблоны README.md НЕ содержат (issue #269): свежий проект рождается без
|
||||
README.
|
||||
- `repo-readme` (через тулзу `create-readme`) **создаёт** полный README с нуля
|
||||
(delimiter tags, bilingual, cover) по ручному вызову после init.
|
||||
- Может применяться к существующим репо без `project-template` — тулза
|
||||
перезапишет `README.md` (локально) или обновит через GitHub API (с SHA).
|
||||
|
||||
## 7. Параметры тулзы (кратко)
|
||||
|
||||
`create-readme`:
|
||||
|
||||
- `mode` — `"create"` | `"validate"` (обязательный).
|
||||
- `mode` — `"create"` (обязательный).
|
||||
- `repo_name`, `tagline_en`, `tagline_ru`, `why_en`, `what_en`, `why_ru`,
|
||||
`what_ru`, `quick_start`, `features_en`, `features_ru` — обязательны для
|
||||
`create`.
|
||||
|
|
@ -315,6 +318,6 @@ Access at [http://localhost:4096](http://localhost:4096)
|
|||
`❌ tagline_en is required for create mode`.
|
||||
|
||||
**Существующие README** (без tagline delimiter-тегов) станут invalid при
|
||||
`validate` — `Missing <!-- tagline-en:start --> delimiter` и
|
||||
проверке `check_readme` — `Missing <!-- tagline-en:start --> delimiter` и
|
||||
`Missing <!-- tagline-ru:start --> delimiter`. Регенерация README через
|
||||
`create` (с новыми параметрами) делается отдельным шагом после merge.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
- **backend**: FastAPI + uvicorn, Tortoise ORM + Aerich, pydantic-settings, Loguru
|
||||
- **fullstack**: backend + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
|
||||
- **backend**: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich — legacy), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
||||
- **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
|
||||
- **cli**: Typer (default) / click / argparse, hatchling build
|
||||
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
||||
- **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 фаз
|
||||
|
||||
### Phase 0: DETECT (subagent, без вопроса юзеру)
|
||||
|
|
@ -61,7 +65,7 @@ Prompt template A (см. ниже).
|
|||
```
|
||||
Выбери тип проекта:
|
||||
[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)
|
||||
[4] cli — Python CLI tool (Typer)
|
||||
[5] bot — Telegram bot (aiogram 3)
|
||||
|
|
@ -84,7 +88,7 @@ backend:
|
|||
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
|
||||
|
||||
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)
|
||||
- Auth: (same as backend)
|
||||
|
||||
|
|
@ -156,7 +160,7 @@ Prompt template F (см. ниже).
|
|||
|
||||
```
|
||||
Дефолтный 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> — ...
|
||||
3. core: <module 2> — ...
|
||||
4. auth (если выбран auth в Phase 2)
|
||||
|
|
@ -231,8 +235,8 @@ Spec complete. Issues: #N1, #N2, ...
|
|||
Тип проекта: <type> (из frontmatter meta.md).
|
||||
Default stack для типа (хардкод, добавить всегда):
|
||||
- Общий: 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
|
||||
- fullstack: + frontend/ (React 19 + Vite + Biome + TS strict + Vitest + Knip + happy-dom)
|
||||
- backend: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
||||
- 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
|
||||
- cli: Typer (default) / click / argparse, hatchling build
|
||||
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
||||
|
|
@ -308,11 +312,12 @@ Default stack для типа (хардкод, добавить всегда):
|
|||
## Контракты (ожидаемое поведение / API)
|
||||
## Инварианты (правила без исключений)
|
||||
## Граничные случаи (что при ошибках)
|
||||
## Влияние на связанные компоненты (зависящие файлы/оракулы/агенты/промпты/валидаторы; paired updates; «нет связанных компонентов» для тривиальных задач)
|
||||
## Вне scope (что НЕ делаем)
|
||||
## Критерии приемки (как проверяем)
|
||||
## Связанные ресурсы (Part of spec, ref к docs/spec/roadmap.md)
|
||||
- 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)
|
||||
4. Собери реальные номера issues из вывода tool.
|
||||
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/
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
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
|
||||
- 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,129 @@
|
|||
[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 = ["cli"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"typer>=0.12",
|
||||
"rich",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=5.0",
|
||||
"pytest-timeout>=2.2",
|
||||
"mypy>=1.10",
|
||||
"ruff>=0.5",
|
||||
"pre-commit>=3.7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
{{ cookiecutter.project_name }} = "{{ cookiecutter.project_name }}.__main__:app"
|
||||
|
||||
[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"]
|
||||
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,3 @@
|
|||
"""{{ cookiecutter.project_name }} package."""
|
||||
|
||||
from {{ cookiecutter.project_name }}.core import __version__ # noqa: F401
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""CLI entry point — ``python -m {{ cookiecutter.project_name }}``."""
|
||||
|
||||
from {{ cookiecutter.project_name }}.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"""Typer CLI commands for {{ cookiecutter.project_name }}."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from {{ cookiecutter.project_name }}.core import greet, __version__
|
||||
|
||||
app = typer.Typer(help="{{ cookiecutter.description }}", no_args_is_help=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def hello(name: str = typer.Argument("world", help="Name to greet")) -> None:
|
||||
"""Print a greeting for the given name."""
|
||||
typer.echo(greet(name))
|
||||
|
||||
|
||||
@app.command()
|
||||
def version() -> None:
|
||||
"""Print the installed version."""
|
||||
typer.echo(__version__)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"""Business logic for {{ cookiecutter.project_name }}.
|
||||
|
||||
The CLI layer (``cli.py``) is thin — all logic lives here so it can be
|
||||
unit-tested without invoking the Typer runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
||||
def greet(name: str) -> str:
|
||||
"""Return a greeting string for the given name."""
|
||||
if not name:
|
||||
return "Hello, stranger!"
|
||||
return f"Hello, {name}!"
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Tests package."""
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"""Pytest configuration for the CLI template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from {{ cookiecutter.project_name }} import cli
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner() -> CliRunner:
|
||||
"""Typer CliRunner — invokes the app in-process."""
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""The Typer app instance."""
|
||||
return cli.app
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"""CLI tests — invoke the Typer app via CliRunner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from {{ cookiecutter.project_name }}.cli import app
|
||||
|
||||
|
||||
def test_hello_default(runner: CliRunner) -> None:
|
||||
"""``hello`` with no arg greets the world."""
|
||||
result = runner.invoke(app, ["hello"])
|
||||
assert result.exit_code == 0
|
||||
assert "Hello, world!" in result.stdout
|
||||
|
||||
|
||||
def test_hello_name(runner: CliRunner) -> None:
|
||||
"""``hello <name>`` greets the given name."""
|
||||
result = runner.invoke(app, ["hello", "Alice"])
|
||||
assert result.exit_code == 0
|
||||
assert "Hello, Alice!" in result.stdout
|
||||
|
||||
|
||||
def test_version(runner: CliRunner) -> None:
|
||||
"""``version`` prints the package version."""
|
||||
result = runner.invoke(app, ["version"])
|
||||
assert result.exit_code == 0
|
||||
assert "0.1.0" in result.stdout
|
||||
|
||||
|
||||
def test_no_args_shows_help(runner: CliRunner) -> None:
|
||||
"""No args → help (no_args_is_help=True)."""
|
||||
result = runner.invoke(app, [])
|
||||
assert result.exit_code != 0 or "Usage" in result.stdout
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"""Unit tests for core business logic (no Typer)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from {{ cookiecutter.project_name }}.core import __version__, greet
|
||||
|
||||
|
||||
def test_greet_name() -> None:
|
||||
"""greet returns a personalized greeting."""
|
||||
assert greet("Alice") == "Hello, Alice!"
|
||||
|
||||
|
||||
def test_greet_empty() -> None:
|
||||
"""greet with empty name falls back to stranger."""
|
||||
assert greet("") == "Hello, stranger!"
|
||||
|
||||
|
||||
def test_version_is_string() -> None:
|
||||
"""__version__ is a non-empty string."""
|
||||
assert isinstance(__version__, str)
|
||||
assert __version__
|
||||
14
.opencode/templates/fullstack/cookiecutter.json
Normal file
14
.opencode/templates/fullstack/cookiecutter.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"project_name": "my_fullstack",
|
||||
"project_type": "fullstack",
|
||||
"description": "Fullstack project description",
|
||||
"use_auth": ["no", "yes"],
|
||||
"use_db": ["yes", "no"],
|
||||
"python_version": "3.13",
|
||||
"_copy_without_render": [
|
||||
"frontend/src/lib/components",
|
||||
"frontend/src/routes/+page.svelte",
|
||||
"frontend/src/routes/+layout.svelte",
|
||||
"frontend/src/routes/+error.svelte"
|
||||
]
|
||||
}
|
||||
56
.opencode/templates/fullstack/hooks/post_gen_project.py
Normal file
56
.opencode/templates/fullstack/hooks/post_gen_project.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Post-generation hook for the fullstack cookiecutter template.
|
||||
|
||||
Removes files conditional on ``use_auth`` / ``use_db`` from the backend
|
||||
sub-tree (the frontend has no such flags).
|
||||
"""
|
||||
|
||||
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"backend/src/{pkg}/api/v1/routes/auth.py")
|
||||
_remove(f"backend/src/{pkg}/services/auth_service.py")
|
||||
_remove("backend/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"backend/src/{pkg}/db")
|
||||
_remove("backend/migrations")
|
||||
_remove(f"backend/src/{pkg}/services/user_service.py")
|
||||
_remove(f"backend/src/{pkg}/api/v1/routes/users.py")
|
||||
_remove(f"backend/src/{pkg}/api/v1/dependencies.py")
|
||||
_remove(f"backend/src/{pkg}/schemas/user.py")
|
||||
_remove("backend/tests/unit/test_user.py")
|
||||
_remove("backend/tests/unit/test_user_service.py")
|
||||
_remove("backend/tests/api/test_users.py")
|
||||
if use_auth == "yes":
|
||||
# auth_service imports User; strip it and its wiring too.
|
||||
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
|
||||
_remove(f"backend/src/{pkg}/services/auth_service.py")
|
||||
_remove("backend/tests/test_auth.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
35
.opencode/templates/fullstack/hooks/pre_gen_project.py
Normal file
35
.opencode/templates/fullstack/hooks/pre_gen_project.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Pre-generation hook for the fullstack 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-fullstack`` used to render as
|
||||
``from my-fullstack.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_fullstack' instead of 'my-fullstack' (or 'my.fullstack', "
|
||||
"'my fullstack')."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
14
.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: pip
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: npm
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
66
.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
66
.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend-lint:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: backend } }
|
||||
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 .
|
||||
|
||||
backend-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: backend } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- run: uv sync --extra dev
|
||||
- run: uv run mypy src tests
|
||||
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: backend } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
- run: uv sync --extra dev
|
||||
- run: uv run pytest
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: frontend } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- run: npm install
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
|
||||
frontend-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: frontend } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- run: npm install
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run build
|
||||
- run: npm run test:e2e
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend-lint, backend-typecheck, backend-test, frontend-test, frontend-e2e]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "All checks passed"
|
||||
|
|
@ -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.
|
||||
10
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/dependabot.yml
vendored
Normal file
10
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.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/fullstack/{{cookiecutter.project_name}}/backend/.github/workflows/ci.yml
vendored
Normal file
42
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.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/fullstack/{{cookiecutter.project_name}}/backend/.gitignore
vendored
Normal file
18
.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.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
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue