feat(tools): ruff/mypy pre-flight gate in create-pr tool #49

Closed
opened 2026-08-11 17:18:15 +03:00 by slaid098 · 0 comments
Owner

Контекст

Зачем: run-pipeline постоянно уходит в loop "CI упал на ruff → fix_ci subagent → push → CI снова падает". Агент делает commit + push + создаёт PR, CI ловит ruff/mypy ошибки которые можно проверить локально. Куча токенов и времени на round-trip Forgejo CI. Локальный deterministic gate перед созданием PR предотвратит этот loop.

Контекст: сейчас run-pipeline/SKILL.md Template A (IMPLEMENT, lines 43-61) шаг 4 — git push сразу после commit tool, шаг 5 — create-pr. НЕТ локальной проверки ruff/mypy перед push/PR. .pre-commit-config.yaml существует (ruff + ruff-format + mypy), но pre-commit install нигде не гарантирован — в worktree/Docker без установленного hook pre-commit не срабатывает. commit.ts:69 делает spawnSync("git", ["commit", ...]) и полагается на git hooks (которые могут быть не активны). Permissions позволяют (uv run ruff* = allow в opencode.json:233), но НЕТ инструкции в skills/agents это делать.

ADR-016 (docs/decisions/016-pr-40-permissions-lock.md:10) + handoff PR-40/PR-46: custom TS tools bypass permission layer через spawnSync (trusted code, не проходит через permission.bash). Это позволяет поставить deterministic gate в TS tool, который НЕ может быть заблокирован permission-правилами.

.pre-commit-config.yaml (ruff-pre-commit v0.15.0 + mirrors-mypy v1.18.2) — отдельный layer defense на commit. Этот issue — второй layer на PR creation (финальная проверка перед публикацией, ловит final state после нескольких коммитов).

Задача

  1. В .opencode/tools/create-pr.ts между line 80 (const defaultBranch = JSON.parse(repo.stdout).default_branch) и line 82 (const r = await runGh(["pr", "create", ...])) вставить pre-flight ruff/mypy gate:
    • Guard на Python репо: spawnSync("test", ["-f", ${cwd}/pyproject.toml]) — если exit != 0 (не Python репо) → skip gate, продолжить к runGh pr create.
    • Если pyproject.toml есть:
      • const ruffFormat = spawnSync("uv", ["run", "ruff", "format", "--check", "src/", "tests/", ".opencode/scripts/"], { encoding: "utf-8", cwd, timeout: 60000 }) — если ruffFormat.status !== 0 → return "❌ ruff format --check failed (exit N):\n<stdout>\n<stderr>\n\nFix locally with: uv run ruff format src/ tests/ .opencode/scripts/\nThen amend + force-push, re-run create-pr.".
      • const ruffCheck = spawnSync("uv", ["run", "ruff", "check", "src/", "tests/", ".opencode/scripts/"], { encoding: "utf-8", cwd, timeout: 60000 }) — если ruffCheck.status !== 0 → return "❌ ruff check failed (exit N):\n...\n\nFix locally with: uv run ruff check --fix src/ tests/ .opencode/scripts/\nThen amend + force-push, re-run create-pr.".
      • const mypy = spawnSync("uv", ["run", "mypy", "src/"], { encoding: "utf-8", cwd, timeout: 120000 }) — если mypy.status !== 0 → return "❌ mypy failed (exit N):\n...\n\nFix type errors locally, amend + force-push, re-run create-pr.".
    • Обработка spawnSync error (uv not found): if (ruffFormat.error) → return "⚠️ uv not found — skip ruff gate (pre-flight skipped, Python detection failed)". (soft fail, НЕ блокировать PR creation — non-Python или uv-not-installed окружение).
    • Обработка timeout: if (ruffFormat.signal === "SIGTERM" && ruffFormat.status === null) → return "⚠️ ruff format --check timed out after 60s — skip gate" (soft fail).
  2. Lint targets ["src/", "tests/", ".opencode/scripts/"] — hardcode для opencode-config репо. Для других репо (cookiecutter templates) эти пути могут отличаться — проверить существование каждой директории перед передачей в ruff (filter через spawnSync("test", ["-d", path])). Несуществующие — исключить из targets.
  3. Обновить tests/_ts_loader.mjs — stub для spawnSync("uv", ...) команд. Добавить env var UV_RUFF_STUB (как GIT_HEAD_STUB на line 384) для управления stub response в тестах: "pass" → status: 0, stdout: "", "fail" → status: 1, stdout: "...", "timeout" → signal: "SIGTERM", status: null, "nopython" → error: new Error("spawn uv ENOENT").
  4. Добавить тесты в tests/test_create_pr_tool.py:
    • test_create_pr_ruff_format_fails — stub ruff format fail → expect ❌ ruff format --check failed, PR НЕ создаётся.
    • test_create_pr_ruff_check_fails — stub ruff check fail → expect ❌ ruff check failed, PR НЕ создаётся.
    • test_create_pr_mypy_fails — stub mypy fail → expect ❌ mypy failed, PR НЕ создаётся.
    • test_create_pr_ruff_passes — stub all pass → expect PR created: ... (PR создаётся, gate пройден).
    • test_create_pr_no_pyproject — cwd без pyproject.toml → gate skip, PR создаётся.
    • test_create_pr_uv_not_found — stub uv ENOENT → soft fail ⚠️ uv not found, PR создаётся.
    • test_create_pr_ruff_timeout — stub timeout → soft fail ⚠️ ... timed out, PR создаётся.
  5. Создать ADR NNN-pr-<this_pr>-create-pr-ruff-preflight.md (номер определи через ls docs/decisions/) — обосновать: выбор create-pr.ts (после push, до PR) vs commit.ts (раньше, но per-commit) vs pipeline-status.py новая фаза (требует PR, ломает 8 тестов zip(strict=True)). Цитировать ADR-016 (TS spawnSync bypass permissions = deterministic gate).

Контракты

  • create-pr({ title, body, issue_number, repo }) — без изменений в signature/args.
  • При ruff format/check/mypy fail → return "❌ <tool> failed (exit N):\n<output>\n\nFix locally with: <command>\nThen amend + force-push, re-run create-pr." (НЕ throw, НЕ создайт PR, НЕ пушит — уже запушено до этого).
  • При non-Python репо (нет pyproject.toml) → gate skip, PR создаётся (без изменений в поведении).
  • При uv not found / timeout → soft fail ⚠️ ..., PR создаётся (gate skip, НЕ блокировать).
  • При all pass → PR создаётся (без изменений).
  • spawnSync timeout: ruff 60s, mypy 120s (mypy cold cache может быть медленным).

Инварианты

  • Gate ТОЛЬКО в create-pr.ts (НЕ в commit.ts — per-commit слишком рано, между коммитами состояние может меняться; НЕ в pipeline-status.py — требует PR номер, ломает 8 тестов zip(strict=True) в format_single_pr).
  • Lint targets: src/, tests/, .opencode/scripts/ — существующие директории filter через test -d.
  • Soft fail при uv/timeout — НЕ блокировать PR creation (non-Python repo, broken environment — не наша проблема).
  • Hard fail при ruff/mypy error — блокировать PR creation (заставить агента фиксить локально).
  • TS spawnSync bypass'ит permission.bash (ADR-016) — gate deterministic, не может быть заблокирован permission-правилами (даже если ruff* будет в deny-list, TS tool работает).

Граничные случаи

  • pyproject.toml есть, но uv не установлен → spawnSync error spawn uv ENOENT → soft fail ⚠️ uv not found, PR создаётся.
  • pyproject.toml есть, uv установлен, но ruff не в deps → uv run ruff упадёт с "Command not found" (exit != 0) → это НЕ ruff format failure, это infrastructure failure → нужно различать: если stderr содержит "Command not found" / "not found" → soft fail (infrastructure), иначе → hard fail (ruff violations). Реализация: проверка ruffFormat.stderr на pattern /not found|Command not found|No such file/i → soft fail.
  • src/ существует, но пустой → ruff format --check src/ exit 0 (no files to check) → pass.
  • .opencode/scripts/ не существует (cookiecutter template без .opencode/) → filter через test -d исключает → targets ["src/", "tests/"] → ruff проверяет что есть.
  • mypy без src/ → uv run mypy src/ упадёт "Cannot find src/" → нужно либо skip mypy если src/ нет, либо передать существующие targets.
  • Агент уже сделал amend + force-push после ruff fail → новый create-pr вызов → ruff снова проверяет (state changed) → если pass → PR создаётся.

Влияние на связанные компоненты

  • run-pipeline/SKILL.md Template A (IMPLEMENT, lines 56-61) — без изменений (агент по-прежнему вызывает create-pr({...}), tool сам решает создавать PR или вернуть ❌).
  • run-pipeline/SKILL.md Template D (fix_ci, line 97) — без изменений (fix_ci тоже создаёт коммит, но НЕ через create-pr — пушит в существующий PR; gate НЕ применяется к fix_ci, только к новым PR. Это ОК — fix_ci пушит в существующий PR, CI повторяется; gate нужен именно перед созданием нового PR).
  • commit.ts — без изменений (pre-commit hook на commit — отдельный layer, этот PR не трогает).
  • pipeline-status.py / pipeline-status.ts — без изменений.
  • tests/_ts_loader.mjs — обновить stub для uv cmd (env var UV_RUFF_STUB).
  • tests/test_create_pr_tool.py — 7 новых тестов (см. Задача).
  • Cookiecutter templates (.opencode/templates/{backend,cli,fullstack}/) — БЕЗ изменений. Templates не имеют своего create-pr tool — они наследуют opencode-config .opencode/tools/. Gate работает во всех репо, использующих opencode-config, автоматически. Lint targets filter через test -d делает gate совместимым с разными layout.

Вне scope

  • ❌ Gate в commit.ts (per-commit) — отдельный вопрос, pre-commit hook уже покрывает commit-time.
  • ❌ Gate в pipeline-status.py новая фаза LINT — ломает 8 тестов zip(strict=True), требует PR номер (не подходит для pre-PR).
  • ❌ Активация pre-commit install в setup/init flow — отдельный PR (этот issue — tool layer, не git hooks).
  • ❌ Gate для non-Python репо (frontend JS/TS) — нужен biome check/eslint, отдельный issue.
  • ❌ Mix runs баг в _forgejo_ci_rollup — отдельный issue (починка оракула, не pre-flight gate).
  • ❌ Утечка веток pr-N — отдельный issue.

Критерии приемки

  • create-pr в Python репо с ruff format violations → ❌ ruff format --check failed, PR НЕ создаётся
  • create-pr в Python репо с ruff check violations → ❌ ruff check failed, PR НЕ создаётся
  • create-pr в Python репо с mypy errors → ❌ mypy failed, PR НЕ создаётся
  • create-pr в Python репо clean (all pass) → PR created: ... (без регрессии)
  • create-pr в non-Python репо (нет pyproject.toml) → gate skip, PR создаётся
  • create-pr когда uv не установлен → soft fail ⚠️ uv not found, PR создаётся
  • create-pr когда ruff timeout (60s) → soft fail ⚠️ timed out, PR создаётся
  • create-pr когда ruff не в deps (infrastructure fail) → soft fail, PR создаётся (НЕ hard fail на ruff violations)
  • 7 новых тестов в test_create_pr_tool.py проходят
  • Существующие tests/test_create_pr_tool.py + tests/_ts_loader.mjs тесты не ломаются
  • ruff format --check . && ruff check . && mypy src/ — green (на самом create-pr.ts)
  • ADR создан (обоснование выбора create-pr.ts vs альтернатив)
## Контекст Зачем: run-pipeline постоянно уходит в loop "CI упал на ruff → fix_ci subagent → push → CI снова падает". Агент делает commit + push + создаёт PR, CI ловит ruff/mypy ошибки которые можно проверить локально. Куча токенов и времени на round-trip Forgejo CI. Локальный deterministic gate перед созданием PR предотвратит этот loop. Контекст: сейчас `run-pipeline/SKILL.md` Template A (IMPLEMENT, lines 43-61) шаг 4 — `git push` сразу после `commit` tool, шаг 5 — `create-pr`. НЕТ локальной проверки ruff/mypy перед push/PR. `.pre-commit-config.yaml` существует (ruff + ruff-format + mypy), но `pre-commit install` нигде не гарантирован — в worktree/Docker без установленного hook pre-commit не срабатывает. `commit.ts:69` делает `spawnSync("git", ["commit", ...])` и полагается на git hooks (которые могут быть не активны). Permissions позволяют (`uv run ruff*` = allow в `opencode.json:233`), но НЕТ инструкции в skills/agents это делать. ADR-016 (`docs/decisions/016-pr-40-permissions-lock.md:10`) + handoff PR-40/PR-46: custom TS tools bypass permission layer через `spawnSync` (trusted code, не проходит через `permission.bash`). Это позволяет поставить deterministic gate в TS tool, который НЕ может быть заблокирован permission-правилами. `.pre-commit-config.yaml` (ruff-pre-commit v0.15.0 + mirrors-mypy v1.18.2) — отдельный layer defense на commit. Этот issue — второй layer на PR creation (финальная проверка перед публикацией, ловит final state после нескольких коммитов). ## Задача 1. В `.opencode/tools/create-pr.ts` между line 80 (`const defaultBranch = JSON.parse(repo.stdout).default_branch`) и line 82 (`const r = await runGh(["pr", "create", ...])`) вставить pre-flight ruff/mypy gate: - Guard на Python репо: `spawnSync("test", ["-f", `${cwd}/pyproject.toml`])` — если exit != 0 (не Python репо) → skip gate, продолжить к `runGh pr create`. - Если `pyproject.toml` есть: - `const ruffFormat = spawnSync("uv", ["run", "ruff", "format", "--check", "src/", "tests/", ".opencode/scripts/"], { encoding: "utf-8", cwd, timeout: 60000 })` — если `ruffFormat.status !== 0` → `return "❌ ruff format --check failed (exit N):\n<stdout>\n<stderr>\n\nFix locally with: uv run ruff format src/ tests/ .opencode/scripts/\nThen amend + force-push, re-run create-pr."`. - `const ruffCheck = spawnSync("uv", ["run", "ruff", "check", "src/", "tests/", ".opencode/scripts/"], { encoding: "utf-8", cwd, timeout: 60000 })` — если `ruffCheck.status !== 0` → `return "❌ ruff check failed (exit N):\n...\n\nFix locally with: uv run ruff check --fix src/ tests/ .opencode/scripts/\nThen amend + force-push, re-run create-pr."`. - `const mypy = spawnSync("uv", ["run", "mypy", "src/"], { encoding: "utf-8", cwd, timeout: 120000 })` — если `mypy.status !== 0` → `return "❌ mypy failed (exit N):\n...\n\nFix type errors locally, amend + force-push, re-run create-pr."`. - Обработка `spawnSync` error (uv not found): `if (ruffFormat.error)` → `return "⚠️ uv not found — skip ruff gate (pre-flight skipped, Python detection failed)".` (soft fail, НЕ блокировать PR creation — non-Python или uv-not-installed окружение). - Обработка timeout: `if (ruffFormat.signal === "SIGTERM" && ruffFormat.status === null)` → `return "⚠️ ruff format --check timed out after 60s — skip gate"` (soft fail). 2. Lint targets `["src/", "tests/", ".opencode/scripts/"]` — hardcode для opencode-config репо. Для других репо (cookiecutter templates) эти пути могут отличаться — проверить существование каждой директории перед передачей в ruff (filter через `spawnSync("test", ["-d", path])`). Несуществующие — исключить из targets. 3. Обновить `tests/_ts_loader.mjs` — stub для `spawnSync("uv", ...)` команд. Добавить env var `UV_RUFF_STUB` (как `GIT_HEAD_STUB` на line 384) для управления stub response в тестах: `"pass"` → `status: 0, stdout: ""`, `"fail"` → `status: 1, stdout: "..."`, `"timeout"` → `signal: "SIGTERM", status: null`, `"nopython"` → `error: new Error("spawn uv ENOENT")`. 4. Добавить тесты в `tests/test_create_pr_tool.py`: - `test_create_pr_ruff_format_fails` — stub ruff format fail → expect `❌ ruff format --check failed`, PR НЕ создаётся. - `test_create_pr_ruff_check_fails` — stub ruff check fail → expect `❌ ruff check failed`, PR НЕ создаётся. - `test_create_pr_mypy_fails` — stub mypy fail → expect `❌ mypy failed`, PR НЕ создаётся. - `test_create_pr_ruff_passes` — stub all pass → expect `PR created: ...` (PR создаётся, gate пройден). - `test_create_pr_no_pyproject` — cwd без `pyproject.toml` → gate skip, PR создаётся. - `test_create_pr_uv_not_found` — stub uv ENOENT → soft fail `⚠️ uv not found`, PR создаётся. - `test_create_pr_ruff_timeout` — stub timeout → soft fail `⚠️ ... timed out`, PR создаётся. 5. Создать ADR `NNN-pr-<this_pr>-create-pr-ruff-preflight.md` (номер определи через `ls docs/decisions/`) — обосновать: выбор `create-pr.ts` (после push, до PR) vs `commit.ts` (раньше, но per-commit) vs `pipeline-status.py` новая фаза (требует PR, ломает 8 тестов `zip(strict=True)`). Цитировать ADR-016 (TS spawnSync bypass permissions = deterministic gate). ## Контракты - `create-pr({ title, body, issue_number, repo })` — без изменений в signature/args. - При ruff format/check/mypy fail → `return "❌ <tool> failed (exit N):\n<output>\n\nFix locally with: <command>\nThen amend + force-push, re-run create-pr."` (НЕ throw, НЕ создайт PR, НЕ пушит — уже запушено до этого). - При non-Python репо (нет `pyproject.toml`) → gate skip, PR создаётся (без изменений в поведении). - При uv not found / timeout → soft fail `⚠️ ...`, PR создаётся (gate skip, НЕ блокировать). - При all pass → PR создаётся (без изменений). - `spawnSync` timeout: ruff 60s, mypy 120s (mypy cold cache может быть медленным). ## Инварианты - Gate ТОЛЬКО в `create-pr.ts` (НЕ в `commit.ts` — per-commit слишком рано, между коммитами состояние может меняться; НЕ в `pipeline-status.py` — требует PR номер, ломает 8 тестов `zip(strict=True)` в `format_single_pr`). - Lint targets: `src/`, `tests/`, `.opencode/scripts/` — существующие директории filter через `test -d`. - Soft fail при uv/timeout — НЕ блокировать PR creation (non-Python repo, broken environment — не наша проблема). - Hard fail при ruff/mypy error — блокировать PR creation (заставить агента фиксить локально). - TS `spawnSync` bypass'ит `permission.bash` (ADR-016) — gate deterministic, не может быть заблокирован permission-правилами (даже если `ruff*` будет в deny-list, TS tool работает). ## Граничные случаи - `pyproject.toml` есть, но `uv` не установлен → `spawnSync` error `spawn uv ENOENT` → soft fail `⚠️ uv not found`, PR создаётся. - `pyproject.toml` есть, `uv` установлен, но `ruff` не в deps → `uv run ruff` упадёт с "Command not found" (exit != 0) → это НЕ ruff format failure, это infrastructure failure → нужно различать: если stderr содержит "Command not found" / "not found" → soft fail (infrastructure), иначе → hard fail (ruff violations). Реализация: проверка `ruffFormat.stderr` на pattern `/not found|Command not found|No such file/i` → soft fail. - `src/` существует, но пустой → `ruff format --check src/` exit 0 (no files to check) → pass. - `.opencode/scripts/` не существует (cookiecutter template без `.opencode/`) → filter через `test -d` исключает → targets `["src/", "tests/"]` → ruff проверяет что есть. - mypy без `src/` → `uv run mypy src/` упадёт "Cannot find src/" → нужно либо skip mypy если `src/` нет, либо передать существующие targets. - Агент уже сделал amend + force-push после ruff fail → новый `create-pr` вызов → ruff снова проверяет (state changed) → если pass → PR создаётся. ## Влияние на связанные компоненты - `run-pipeline/SKILL.md` Template A (IMPLEMENT, lines 56-61) — без изменений (агент по-прежнему вызывает `create-pr({...})`, tool сам решает создавать PR или вернуть `❌`). - `run-pipeline/SKILL.md` Template D (fix_ci, line 97) — без изменений (fix_ci тоже создаёт коммит, но НЕ через `create-pr` — пушит в существующий PR; gate НЕ применяется к fix_ci, только к новым PR. Это ОК — fix_ci пушит в существующий PR, CI повторяется; gate нужен именно перед созданием нового PR). - `commit.ts` — без изменений (pre-commit hook на commit — отдельный layer, этот PR не трогает). - `pipeline-status.py` / `pipeline-status.ts` — без изменений. - `tests/_ts_loader.mjs` — обновить stub для `uv` cmd (env var `UV_RUFF_STUB`). - `tests/test_create_pr_tool.py` — 7 новых тестов (см. Задача). - Cookiecutter templates (`.opencode/templates/{backend,cli,fullstack}/`) — БЕЗ изменений. Templates не имеют своего `create-pr` tool — они наследуют opencode-config `.opencode/tools/`. Gate работает во всех репо, использующих opencode-config, автоматически. Lint targets filter через `test -d` делает gate совместимым с разными layout. ## Вне scope - ❌ Gate в `commit.ts` (per-commit) — отдельный вопрос, pre-commit hook уже покрывает commit-time. - ❌ Gate в `pipeline-status.py` новая фаза LINT — ломает 8 тестов `zip(strict=True)`, требует PR номер (не подходит для pre-PR). - ❌ Активация `pre-commit install` в setup/init flow — отдельный PR (этот issue — tool layer, не git hooks). - ❌ Gate для non-Python репо (frontend JS/TS) — нужен `biome check`/`eslint`, отдельный issue. - ❌ Mix runs баг в `_forgejo_ci_rollup` — отдельный issue (починка оракула, не pre-flight gate). - ❌ Утечка веток `pr-N` — отдельный issue. ## Критерии приемки - [ ] `create-pr` в Python репо с ruff format violations → `❌ ruff format --check failed`, PR НЕ создаётся - [ ] `create-pr` в Python репо с ruff check violations → `❌ ruff check failed`, PR НЕ создаётся - [ ] `create-pr` в Python репо с mypy errors → `❌ mypy failed`, PR НЕ создаётся - [ ] `create-pr` в Python репо clean (all pass) → `PR created: ...` (без регрессии) - [ ] `create-pr` в non-Python репо (нет `pyproject.toml`) → gate skip, PR создаётся - [ ] `create-pr` когда `uv` не установлен → soft fail `⚠️ uv not found`, PR создаётся - [ ] `create-pr` когда ruff timeout (60s) → soft fail `⚠️ timed out`, PR создаётся - [ ] `create-pr` когда `ruff` не в deps (infrastructure fail) → soft fail, PR создаётся (НЕ hard fail на ruff violations) - [ ] 7 новых тестов в `test_create_pr_tool.py` проходят - [ ] Существующие `tests/test_create_pr_tool.py` + `tests/_ts_loader.mjs` тесты не ломаются - [ ] `ruff format --check . && ruff check . && mypy src/` — green (на самом `create-pr.ts`) - [ ] ADR создан (обоснование выбора `create-pr.ts` vs альтернатив)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slaid098/opencode-config#49
No description provided.