feat(tools): ruff/mypy pre-flight gate in create-pr tool #49
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Контекст
Зачем: 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.mdTemplate A (IMPLEMENT, lines 43-61) шаг 4 —git pushсразу послеcommittool, шаг 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 после нескольких коммитов).Задача
.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: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.".spawnSyncerror (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 окружение).if (ruffFormat.signal === "SIGTERM" && ruffFormat.status === null)→return "⚠️ ruff format --check timed out after 60s — skip gate"(soft fail).["src/", "tests/", ".opencode/scripts/"]— hardcode для opencode-config репо. Для других репо (cookiecutter templates) эти пути могут отличаться — проверить существование каждой директории перед передачей в ruff (filter черезspawnSync("test", ["-d", path])). Несуществующие — исключить из targets.tests/_ts_loader.mjs— stub дляspawnSync("uv", ...)команд. Добавить env varUV_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").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 → expectPR 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 создаётся.NNN-pr-<this_pr>-create-pr-ruff-preflight.md(номер определи черезls docs/decisions/) — обосновать: выборcreate-pr.ts(после push, до PR) vscommit.ts(раньше, но per-commit) vspipeline-status.pyновая фаза (требует PR, ломает 8 тестовzip(strict=True)). Цитировать ADR-016 (TS spawnSync bypass permissions = deterministic gate).Контракты
create-pr({ title, body, issue_number, repo })— без изменений в signature/args.return "❌ <tool> failed (exit N):\n<output>\n\nFix locally with: <command>\nThen amend + force-push, re-run create-pr."(НЕ throw, НЕ создайт PR, НЕ пушит — уже запушено до этого).pyproject.toml) → gate skip, PR создаётся (без изменений в поведении).⚠️ ..., PR создаётся (gate skip, НЕ блокировать).spawnSynctimeout: ruff 60s, mypy 120s (mypy cold cache может быть медленным).Инварианты
create-pr.ts(НЕ вcommit.ts— per-commit слишком рано, между коммитами состояние может меняться; НЕ вpipeline-status.py— требует PR номер, ломает 8 тестовzip(strict=True)вformat_single_pr).src/,tests/,.opencode/scripts/— существующие директории filter черезtest -d.spawnSyncbypass'итpermission.bash(ADR-016) — gate deterministic, не может быть заблокирован permission-правилами (даже еслиruff*будет в deny-list, TS tool работает).Граничные случаи
pyproject.tomlесть, ноuvне установлен →spawnSyncerrorspawn 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 проверяет что есть.src/→uv run mypy src/упадёт "Cannot find src/" → нужно либо skip mypy еслиsrc/нет, либо передать существующие targets.create-prвызов → ruff снова проверяет (state changed) → если pass → PR создаётся.Влияние на связанные компоненты
run-pipeline/SKILL.mdTemplate A (IMPLEMENT, lines 56-61) — без изменений (агент по-прежнему вызываетcreate-pr({...}), tool сам решает создавать PR или вернуть❌).run-pipeline/SKILL.mdTemplate 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 дляuvcmd (env varUV_RUFF_STUB).tests/test_create_pr_tool.py— 7 новых тестов (см. Задача)..opencode/templates/{backend,cli,fullstack}/) — БЕЗ изменений. Templates не имеют своегоcreate-prtool — они наследуют opencode-config.opencode/tools/. Gate работает во всех репо, использующих opencode-config, автоматически. Lint targets filter черезtest -dделает gate совместимым с разными layout.Вне scope
commit.ts(per-commit) — отдельный вопрос, pre-commit hook уже покрывает commit-time.pipeline-status.pyновая фаза LINT — ломает 8 тестовzip(strict=True), требует PR номер (не подходит для pre-PR).pre-commit installв setup/init flow — отдельный PR (этот issue — tool layer, не git hooks).biome check/eslint, отдельный issue._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)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)create-pr.tsvs альтернатив)