ORCH-073: SHA-in-main merge-verify + main regression guard #77
21
.env.example
21
.env.example
@@ -50,6 +50,27 @@ ORCH_MERGE_RETEST_TARGET=tests/
|
||||
ORCH_MERGE_LOCK_TIMEOUT_S=300
|
||||
ORCH_MERGE_DEFER_DELAY_S=60
|
||||
ORCH_MERGE_DEFER_MAX_ATTEMPTS=5
|
||||
# ORCH-071/073: merge-verify under-gate on the `deploy -> done` edge (врезка in
|
||||
# advance_stage, NOT a new STAGE_TRANSITIONS edge / registered QG). A deterministic
|
||||
# merge-actor merges the feature code-PR via the Gitea PR-merge API (never push/
|
||||
# force-push to main), then `done` is allowed ONLY when the deployed SHA is proven an
|
||||
# ancestor of origin/main (ORCH-073 FR-1: SHA-in-main is the single criterion; a
|
||||
# merged PR alone no longer confirms). A secondary regression guard then checks a
|
||||
# declarative marker set (MAIN_REGRESSION_MARKERS) is still in origin/main; a missing
|
||||
# marker -> alert + HOLD (NOT done), a git error of the grep itself -> fail-open.
|
||||
# MERGE_VERIFY_ENABLED -> global kill-switch (false -> strictly pre-ORCH-071).
|
||||
# MERGE_VERIFY_REPOS -> CSV of repos where the under-gate is REAL; empty ->
|
||||
# only the self-hosting repo (orchestrator); non-self -> no-op.
|
||||
# MERGE_PR_TIMEOUT_S -> per Gitea list/merge HTTP call timeout.
|
||||
# MERGE_VERIFY_TIMEOUT_S -> git fetch/merge-base timeout for the ancestor + marker checks.
|
||||
# REGRESSION_GUARD_ENABLED -> kill-switch for the ORCH-073 main-integrity regression
|
||||
# guard (false -> SHA-in-main alone gates done); reuses the
|
||||
# merge-verify scope, so non-self repos are a no-op.
|
||||
ORCH_MERGE_VERIFY_ENABLED=true
|
||||
ORCH_MERGE_VERIFY_REPOS=
|
||||
ORCH_MERGE_PR_TIMEOUT_S=60
|
||||
ORCH_MERGE_VERIFY_TIMEOUT_S=60
|
||||
ORCH_REGRESSION_GUARD_ENABLED=true
|
||||
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
|
||||
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
|
||||
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three
|
||||
|
||||
13
.gitattributes
vendored
Normal file
13
.gitattributes
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# ORCH-073 (ADR-001 Р-5 / FR-4): union merge for the append-only changelog.
|
||||
#
|
||||
# CHANGELOG.md is append-only at the top (## [Unreleased]). Without a merge driver,
|
||||
# two branches that both add an Unreleased entry collide on auto_rebase_onto_main
|
||||
# (merge_gate), which rolls the branch back to `development` and can drag in stale
|
||||
# neighbouring code (a phantom-merge amplifier — see ADR-001 root cause #3). The
|
||||
# built-in `union` driver keeps BOTH sides' lines instead of conflicting, so both
|
||||
# changelog entries survive and the branch is not rolled back.
|
||||
#
|
||||
# Scope is INTENTIONALLY limited to CHANGELOG.md: `union` only suits strictly
|
||||
# append-only files. docs/**/*.md (README, ADR, internals) are rewritten line-by-line,
|
||||
# where `union` would silently duplicate edited lines — so they are NOT included.
|
||||
CHANGELOG.md merge=union
|
||||
@@ -3,6 +3,7 @@
|
||||
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
|
||||
|
||||
## [Unreleased]
|
||||
- **CRIT: системный фикс эрозии `main` — SHA-в-main как единственный критерий merge-verify + регресс-гард + `.gitattributes`** (ORCH-073): устранён корень фантомного merge, из-за которого код задач ORCH-067 (`plane_issue_link`) и ORCH-069 (`qg0_title_max`) дошёл до `done`, но физически отсутствовал в `origin/main` (в `main` попадали только их авто docs-PR). **(FR-1)** `merge_gate.verify_merged_to_main` подтверждает merge **ТОЛЬКО** прямым фактом `git merge-base --is-ancestor <validated_sha> origin/main` — OR-ветка `pr_already_merged` удалена (merged PR больше не подтверждает merge); пустой SHA / git-ошибка → `False` (fail-closed, never-raise). **(FR-2)** `pr_already_merged` понижен до idempotency-guard для `merge_pr` и засчитывает PR лишь при `merged & head.ref==<branch> & base.ref=="main"` (явный in-loop фильтр вместо ненадёжного query-параметра `head` — исключает авто docs-PR). **(FR-3)** `merge_pr` выбирает open code-PR строго по `head.ref==<branch>` И `base.ref=="main"`; merge только через Gitea PR-merge API, никогда push/force-push в `main`. **(FR-5)** новый детерминированный регресс-гард `merge_gate.check_main_regression` в `_handle_merge_verify` ПОСЛЕ подтверждённого SHA-в-main и ДО `done` проверяет, что `origin/main` содержит декларативный append-only набор маркеров ранее-merged задач (`MAIN_REGRESSION_MARKERS`, `git grep -c <marker> origin/main -- <path>`); детерминированный `count==0` → alert «main regressed» + HOLD (`set_issue_blocked` + Telegram + Plane, задача НЕ `done`, БЕЗ авто-отката на `development`), git-ошибка самого грепа → fail-OPEN (не блокирует, SHA-в-main остаётся первичным гейтом). Kill-switch `ORCH_REGRESSION_GUARD_ENABLED` (дефолт `true`), область — `merge_verify_applies` (self-hosting / `merge_verify_repos`), non-self → no-op. **(FR-4)** корневой `.gitattributes` с `CHANGELOG.md merge=union` — правки `## [Unreleased]` авто-сливаются при `auto_rebase_onto_main` без конфликта (обе записи сохраняются), ветка не откатывается в `development` и не тащит устаревший код-сосед; `docs/**` под union НЕ ставится. `GET /queue::merge_verify_status` дополнен счётчиком `main_regressed_alerts_total` (read-only). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`), `check_deploy_status`/`_parse_deploy_status`, merge-gate, image-freshness, схема БД, внешние HTTP-эндпоинты; non-self (enduro) merge/verify/гард — no-op (INV-5); ручной `Confirm Deploy` сохранён. ADR `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md` (+ сквозной `adr-0014`). Документация: `docs/architecture/README.md`, `.env.example`. Тесты: `tests/test_orch073_*.py` (TC-01..18).
|
||||
- **Конфигурируемый верхний лимит длины заголовка QG-0 (`ORCH_QG0_TITLE_MAX`, дефолт 200)** (ORCH-069): хардкод `if len(name) > 80` во входной валидации `_qg0_errors` (`src/webhooks/plane.py`) вынесен в настраиваемый параметр `Settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200). Лимит 80 был гигиеническим, а не структурным (slug режется независимо `[:30]`, `tasks.title TEXT` без ограничения), поэтому валидные заголовки 81–200 символов отклонялись на входе без бизнес-причины. Лимит читается из `settings.qg0_title_max` динамически на каждый вызов (тесты патчат значение), текст ошибки подставляет актуальное число; граница строгая (`len > limit` → FAIL, `len == limit` → PASS). **Graceful-деградация (AC-3, self-hosting safety):** пустое/нечисловое значение env не роняет процесс на старте — `field_validator(mode="before")` `_qg0_title_max_default` в `src/config.py` перехватывает сырое env ДО `int`-парсинга pydantic и при невалидном/пустом входе возвращает дефолт 200 (never-raise), гася `ValidationError`. Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят (AC-7). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (QG-0 — inline-валидация входа, не зарегистрированный stage-gate), схема БД, slug-логика `[:30]`, нижние лимиты (`< 5` title, `< 20` description), soft-QG-0 поведение (warning на `work_item.created`), API. ADR `docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md`. Документация: `.env.example`, `.env.staging.example`. Тесты: `tests/test_qg0_title_limit.py`.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -140,25 +140,44 @@ merge-в-main вообще**. Detached host-деплой лишь retag'ал о
|
||||
- **Merge в Phase C (после рестарта), НЕ в Phase B** — finalizer restart-surviving (claim воркером
|
||||
нового контейнера, re-drive reaper'ом), merge физически строго ПОСЛЕ рестарта прода → рестарт его
|
||||
не убивает (G3 «шаг, переживающий рестарт»; постмортем-урок №3).
|
||||
- **Merge-актор `merge_gate.merge_pr`** — `pr_already_merged` (no-op повтор, ORCH-065) → иначе
|
||||
Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Никогда push/force-push в `main`.
|
||||
- **Верификатор `merge_gate.verify_merged_to_main`** — `PR.merged==true` ИЛИ
|
||||
`git merge-base --is-ancestor <validated_sha> origin/main` (`validated_revision` — тот же якорь,
|
||||
что у ORCH-058). never-raise → `False`.
|
||||
- **Merge-актор `merge_gate.merge_pr`** — `pr_already_merged` (idempotency no-op повтор) → иначе
|
||||
Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Выбор PR строго по `head.ref==branch`
|
||||
И `base.ref=="main"`. Никогда push/force-push в `main`.
|
||||
- **Верификатор `merge_gate.verify_merged_to_main` (семантика ORCH-073, FR-1):** подтверждение —
|
||||
**ТОЛЬКО** `git merge-base --is-ancestor <validated_sha> origin/main` (`validated_revision` —
|
||||
якорь ORCH-058). PR-флаг `pr_already_merged` **больше НЕ подтверждает merge** (удалён из verify):
|
||||
он понижен до idempotency-guard `merge_pr` и засчитывает merged PR лишь при `head.ref==branch`
|
||||
И `base.ref=="main"` (исключает авто docs-PR). Пустой SHA / git-ошибка → `False` (fail-closed),
|
||||
never-raise.
|
||||
- **Регресс-гард целостности `main` (ORCH-073, FR-5):** `merge_gate.check_main_regression` в
|
||||
`_handle_merge_verify` ПОСЛЕ подтверждённого SHA-в-main и ДО `done` проверяет, что `origin/main`
|
||||
содержит декларативный набор маркеров ранее-merged задач (`MAIN_REGRESSION_MARKERS`,
|
||||
`git grep -c <marker> origin/main -- <path>` > 0). Маркер отсутствует → alert «main regressed» +
|
||||
HOLD (НЕ `done`, ALERT-only). Fail-open на git-ошибке грепа (регресс — только при `count==0`).
|
||||
Kill-switch `regression_guard_enabled`; non-self → no-op. Набор — append-only константа,
|
||||
значимая задача дописывает свой маркер.
|
||||
- **Не подтверждено → alert «deploy succeeded but not merged» (Telegram+Plane) + HOLD**
|
||||
(`set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на `development` — not-merged есть
|
||||
инфра-дефект, реакция ALERT-only как ORCH-021 self-hosting). Подтверждено → штатный `deploy →
|
||||
done` + `merged_to_main: true` во frontmatter `14-deploy-log.md` (`deploy_status:` нетронут).
|
||||
- **Защита от CHANGELOG-затирания (ORCH-073, FR-4):** корневой `.gitattributes` с
|
||||
`CHANGELOG.md merge=union` → правки `## [Unreleased]` авто-сливаются при `auto_rebase_onto_main`
|
||||
без конфликта, ветка не откатывается в `development` и не тащит устаревший код-сосед. `docs/**`
|
||||
под union НЕ ставится (union только для append-only).
|
||||
- **Условность как ORCH-35/43/58:** `merge_verify_enabled` (kill-switch, дефолт `true`) +
|
||||
`merge_verify_repos` (пусто → только self-hosting); non-self — no-op, merge остаётся за `deployer`.
|
||||
never-raise; идемпотентность (`pr_already_merged`, INV-5); ручной approve сохранён (`Confirm Deploy`).
|
||||
never-raise; идемпотентность по **SHA-в-main** (INV-4, не «любой merged PR»); ручной approve
|
||||
сохранён (`Confirm Deploy`).
|
||||
- **Инварианты:** `STAGE_TRANSITIONS`, `check_deploy_status`/`_parse_deploy_status`, реестр
|
||||
`QG_CHECKS` (под-гейт — врезка в `advance_stage`, НЕ новый зарегистрированный QG), схема БД,
|
||||
БАГ-8, terminal-sync, merge-gate, image-freshness, exit-коды хука — **без изменений**.
|
||||
Диагностика фантома — runbook `docs/operations/PHANTOM_MERGE_RUNBOOK.md` (4 проверки постмортема).
|
||||
|
||||
Подробнее: [adr-0013](adr/adr-0013-merge-verify-gate.md), детально —
|
||||
`docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`.
|
||||
Подробнее: [adr-0013](adr/adr-0013-merge-verify-gate.md) +
|
||||
[adr-0014](adr/adr-0014-merge-verify-sha-source-of-truth.md) (amends 0013 — SHA-в-main как
|
||||
единственный критерий + регресс-гард, ORCH-073); детально —
|
||||
`docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`,
|
||||
`docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`.
|
||||
|
||||
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
|
||||
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
|
||||
|
||||
@@ -17,11 +17,15 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
|
||||
| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 |
|
||||
| adr-0011 | Job-reaper + проактивный реклейм merge-lease | accepted | 2026-06-07 | ORCH-065 |
|
||||
| adr-0012 | Security-гейт (secrets/deps) | accepted | 2026-06-08 | ORCH-022 |
|
||||
| adr-0013 | Merge-в-main + пост-деплой верификация как условие `done` | accepted | 2026-06-08 | ORCH-071 |
|
||||
| adr-0014 | SHA-в-main — единственный критерий merge-verify + регресс-гард | accepted | 2026-06-08 | ORCH-073 |
|
||||
|
||||
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
||||
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
||||
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
||||
> свободный номер (текущий максимум — `0011`).
|
||||
> свободный номер (текущий максимум — `0014`).
|
||||
> adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»).
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# adr-0014: SHA-в-main — единственный критерий merge-verify + регресс-гард целостности `main`
|
||||
|
||||
- **Статус:** accepted
|
||||
- **Дата:** 2026-06-08
|
||||
- **Задача:** ORCH-073 (BUG CRITICAL — эрозия `main`)
|
||||
- **Amends:** [adr-0013](adr-0013-merge-verify-gate.md) (ORCH-071) — меняет КРИТЕРИЙ подтверждения merge.
|
||||
- **Детальный ADR:** `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`
|
||||
- **Постмортем:** `docs/history/LESSONS_2026-06-08_phantom-merge.md`
|
||||
|
||||
## Контекст
|
||||
|
||||
adr-0013 (ORCH-071) ввёл под-гейт merge-verify на ребре `deploy → done`, но допускал
|
||||
подтверждение merge по **ИЛИ-критерию**: `verify_merged_to_main` возвращал `True`, если
|
||||
`pr_already_merged(repo, branch)` **ЛИБО** SHA — предок `origin/main`. `pr_already_merged`
|
||||
засчитывал **любой** merged PR ветки, включая авто docs-PR (staging/deploy-логи). У одной
|
||||
feature-ветки в `main` сливались только docs-PR, а code-PR — нет → `pr_already_merged`=`True` →
|
||||
verify `CONFIRMED` → `done`, хотя кода в `main` не было. Накопительно потеряны ORCH-067 (ссылки
|
||||
`plane_issue_link`) и ORCH-069 (`qg0_title_max`). Вторичный усилитель — CHANGELOG-ребейзы,
|
||||
откатывающие ветку и тащащие устаревший код-сосед. Восстановление кода (G1) выполнено вручную
|
||||
restore-PR #76; этот ADR устраняет корень навсегда.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **SHA-в-main — единственный критерий (FR-1).** `verify_merged_to_main(repo, branch, sha)`
|
||||
подтверждает merge **ТОЛЬКО** прямым фактом `git merge-base --is-ancestor <sha> origin/main`
|
||||
(после `git fetch origin main`). OR-ветка `pr_already_merged` **удалена** из верификатора.
|
||||
Пустой `sha` / любая git-ошибка → `False` (fail-closed: alert + HOLD). never-raise (INV-1).
|
||||
2. **`pr_already_merged` → idempotency-guard, различающий code-PR/docs-PR (FR-2).** Засчитывает
|
||||
merged PR только при `head.ref==<feature-branch>` И `base.ref=="main"` (явный фильтр в цикле,
|
||||
не ненадёжный query-параметр `head`). Используется лишь как защита `merge_pr` от второго merge,
|
||||
НЕ как подтверждение `done`.
|
||||
3. **`merge_pr` сливает именно code-ветку (FR-3).** Выбор открытого PR по `head.ref==branch` И
|
||||
`base.ref=="main"`; merge только Gitea `POST /pulls/{index}/merge`, никогда push/force-push в
|
||||
`main`. Источник истины «слилось» — FR-1.
|
||||
4. **Регресс-гард целостности `main` (FR-5).** Новая `merge_gate.check_main_regression`,
|
||||
вызываемая в `_handle_merge_verify` ПОСЛЕ подтверждённого SHA-в-main и ДО `done`: проверяет, что
|
||||
`origin/main` содержит **декларативный набор маркеров** ключевых функций ранее-merged задач
|
||||
(`git grep -c <marker> origin/main -- <path>` > 0). Маркер отсутствует → **alert «main
|
||||
regressed» + HOLD** (НЕ `done`, БЕЗ авто-отката на `development` — инфра-дефект, ALERT-only как
|
||||
ORCH-021/071). Набор — append-only константа `MAIN_REGRESSION_MARKERS` в `merge_gate.py`
|
||||
(расширяется каждой значимой задачей). **Fail-open** на git-ошибке самого грепа (регресс
|
||||
утверждается только при детерминированном `count==0`); первичный фейл-клозед — SHA-в-main.
|
||||
Kill-switch `regression_guard_enabled` (дефолт `true`); non-self → no-op.
|
||||
5. **`.gitattributes CHANGELOG.md merge=union` (FR-4).** В корне репо; авто-слияние правок
|
||||
`## [Unreleased]` без конфликта → `auto_rebase_onto_main` не откатывает ветку и не тащит
|
||||
устаревший код-сосед. `docs/**/*.md` под union **НЕ** ставится (union только для append-only;
|
||||
доки переписываются построчно).
|
||||
|
||||
## Инварианты
|
||||
|
||||
never-raise на verify/merge/регресс-гарде (ошибка → alert/HOLD, не падение); прод 8500 не
|
||||
рестартится/не падает в рамках merge; merge только Gitea PR-API без force-push в `main`; ручной
|
||||
`Confirm Deploy` (ORCH-059) сохранён; идемпотентность по «SHA-в-main», а не по «любому merged PR»;
|
||||
non-self репо (enduro) — merge/verify/регресс-гард без изменений. `STAGE_TRANSITIONS`, реестр
|
||||
`QG_CHECKS`, `check_deploy_status`, схема БД, внешние HTTP-эндпоинты — **без изменений**.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- Сохранить PR-флаг как со-критерий verify (с фильтром head/base) — отклонено: PR можно слить и
|
||||
тут же откатить ребейзом-соседом; надёжен только факт «SHA в main».
|
||||
- `docs/**/*.md merge=union` — отклонено: тихая дубликация строк в переписываемых доках.
|
||||
- Регресс-гард с авто-откатом / хранением маркеров в БД/Plane — отклонено (Не-цель «не менять
|
||||
схему БД/Plane»; реакция ALERT-only).
|
||||
- Fail-closed на marker-grep — отклонено: ложный HOLD при git-сбое; marker-grep вторичен.
|
||||
|
||||
## Последствия
|
||||
|
||||
Невозможно «`done` + прод задеплоен, а code-PR не в `main`». Ложно-зелёный по docs-PR устранён в
|
||||
корне. CHANGELOG-конфликты больше не откатывают ветку. Регресс соседнего кода ловится отдельным
|
||||
гардом. Минус: при недоступной Gitea/git verify консервативно `False` → возможен ложный HOLD+alert
|
||||
(снимается повтором; fail-closed для `done` приоритетен). Набор маркеров требует дисциплины —
|
||||
значимая задача дописывает свой маркер.
|
||||
|
||||
## Связи
|
||||
|
||||
- Amends adr-0013 (ORCH-071), наследует adr-0006 (merge-gate), adr-0011 (job-reaper/lease).
|
||||
- Детально: `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`.
|
||||
7
docs/work-items/ORCH-073/00-business-request.md
Normal file
7
docs/work-items/ORCH-073/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: CRIT: эрозия main — код ORCH-067/069 затёрт ребейзами, не доехал
|
||||
|
||||
Work Item ID: ORCH-073
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
98
docs/work-items/ORCH-073/01-brd.md
Normal file
98
docs/work-items/ORCH-073/01-brd.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 01 — BRD: ORCH-073 — CRIT: эрозия main (код ORCH-067/069 затёрт ребейзами, не доехал)
|
||||
|
||||
- **Work Item:** ORCH-073
|
||||
- **Тип:** BUG CRITICAL — целостность `main`, накопительный регресс/эрозия
|
||||
- **Репозиторий:** orchestrator (self-hosting)
|
||||
- **Ветка:** `feature/ORCH-073-crit-main-orch-067-069`
|
||||
- **Связь:** усиливает/чинит ORCH-071 (merge-verify); НЕ покрыт ORCH-071.
|
||||
|
||||
## 1. Бизнес-проблема
|
||||
|
||||
Код успешно «задеплоенных» и переведённых в `done` задач **ORCH-067** (tracker bump,
|
||||
Plane-статусы, кликабельные ссылки `plane_issue_link`) и **ORCH-069** (`qg0_title_max`)
|
||||
**физически отсутствовал в `origin/main`**, хотя обе прошли весь конвейер, Confirm Deploy,
|
||||
merge-verify `CONFIRMED` и стали `done`. В `main` попадали только их **docs-коммиты**
|
||||
(staging-log / verdict через отдельные авто docs-PR), но НЕ код feature-веток.
|
||||
|
||||
Внешнее проявление (нашёл Слава, 08.06): «ссылок на задачу в Plane нет», карточка Telegram
|
||||
показывает сырой номер задачи вместо кликабельной ссылки — потому что код ссылок есть в ветке
|
||||
ORCH-067, но не в `main`.
|
||||
|
||||
**Накопительный характер:** каждая новая задача срезает ветку от УСТАРЕВШЕГО `main` и при merge
|
||||
тихо (без конфликт-маркеров) затирает код предшественника. Уже потеряны ORCH-067 и ORCH-069;
|
||||
без системного фикса теряется код каждой следующей задачи с правкой `CHANGELOG.md`.
|
||||
|
||||
## 2. Подтверждённый root cause (git-аудит 08.06, не гипотеза)
|
||||
|
||||
1. **`verify_merged_to_main` подтверждает merge по ложному признаку.**
|
||||
`src/merge_gate.py::verify_merged_to_main` возвращает `True`, если выполнено **ЛИБО**
|
||||
`pr_already_merged(repo, branch)`, **ЛИБО** `git merge-base --is-ancestor <sha> origin/main`.
|
||||
Первая ветка (`pr_already_merged`) и есть дыра.
|
||||
2. **`pr_already_merged` засчитывает ЛЮБОЙ merged PR ветки.**
|
||||
`src/merge_gate.py::pr_already_merged` делает `GET /pulls?state=all&head=<branch>` и
|
||||
возвращает `True`, если **хоть один** PR `merged==True`. У одной ветки несколько PR
|
||||
(code-PR + авто docs-PR со staging/deploy-логами). Сливается docs-PR → функция говорит
|
||||
«already-merged» → `verify_merged_to_main`=`True` → merge-verify `CONFIRMED` → `done`,
|
||||
хотя code-PR НЕ слит. **Ложно-зелёный.**
|
||||
3. **CHANGELOG.md-ребейзы — вторичный усилитель.**
|
||||
Merge-gate `auto_rebase_onto_main` при конфликте `CHANGELOG.md` откатывает `deploy-staging →
|
||||
development`; повторный ребейз ветки от старого `main` несёт устаревшие версии файлов
|
||||
(`notifications.py`/`config.py`/`webhooks/plane.py`), которые при merge тихо затирают
|
||||
соседний код (фантом-эффект, как в ORCH-071, без конфликт-маркеров).
|
||||
|
||||
> Уточнение для архитектора: в ТЗ упомянута «инвертированная проверка `merge-base --is-ancestor
|
||||
> origin/main HEAD` (merge_gate.py ~76)» — это `branch_is_behind_main` (детектор «ветка
|
||||
> свежая»), он корректен для своей цели. Фактический дефект merge-verify — это OR-ветка
|
||||
> `pr_already_merged` в `verify_merged_to_main` (строка ~649), которая засчитывает docs-PR.
|
||||
|
||||
## 3. Состояние на момент анализа (G1)
|
||||
|
||||
Аудит `origin/main` показал, что **восстановительный PR #76** (`restore(main): re-merge
|
||||
ORCH-067 + ORCH-069 (ORCH-073)`) уже вернул код в `main`:
|
||||
- `plane_issue_link` присутствует (`src/notifications.py`), `qg0_title_max` присутствует
|
||||
(`src/config.py`, `src/webhooks/plane.py`), `verify_merged_to_main` присутствует.
|
||||
|
||||
Таким образом **G1 (восстановление кода) фактически выполнено** ручным restore-PR. Задача
|
||||
ORCH-073 должна **подтвердить и зафиксировать** это в критериях приёмки (AC-1) и сосредоточиться
|
||||
на **системном фиксе навсегда** (G2–G5 / FR-1…FR-5), иначе регресс повторится.
|
||||
|
||||
## 4. Цели (Goals)
|
||||
|
||||
- **G1.** КОД ORCH-067 и ORCH-069 присутствует в `origin/main` одновременно с ORCH-071
|
||||
(подтвердить restore-PR #76, зафиксировать маркеры > 0). Pytest зелёный. Прод задеплоен.
|
||||
- **G2 (FR-2/FR-3).** `merge`/`pr_already_merged` различают **code-PR** и **docs-PR** — merge
|
||||
засчитывается только за PR с кодом ветки (`base==main`, `head==<feature-branch>`).
|
||||
- **G3 (FR-1, ядро).** `verify_merged_to_main` подтверждает merge **ТОЛЬКО** по факту «deployed
|
||||
SHA — предок `origin/main`». PR-флаги вспомогательны, не достаточны.
|
||||
- **G4 (FR-4).** Защита от CHANGELOG-затирания: `.gitattributes` с `CHANGELOG.md merge=union`
|
||||
(+ опц. `docs/*.md merge=union` для append-only).
|
||||
- **G5 (FR-5, регресс-гард навсегда).** После деплоя — sanity-проверка целостности `main`:
|
||||
deployed SHA в `main` И набор маркеров ранее-merged задач не уменьшился. Откат соседнего кода
|
||||
→ alert «main regressed», задача НЕ `done`.
|
||||
|
||||
## 5. Не-цели (Out of scope)
|
||||
|
||||
- Не менять Plane / схему БД.
|
||||
- Не отменять self-hosting safety (не ронять прод, merge только через PR-API, без force-push в `main`).
|
||||
- Не менять ручной гейт `Confirm Deploy`.
|
||||
- Не менять поведение merge/verify для non-self репозиториев (enduro-trails) — обратная совместимость.
|
||||
|
||||
## 6. Инварианты
|
||||
|
||||
- **INV-1.** never-raise на верификации (alert, не падение).
|
||||
- **INV-2.** self-hosting safety: прод не падает; merge только PR-API, без force-push в `main`.
|
||||
- **INV-3.** ручной `Confirm Deploy` сохранён.
|
||||
- **INV-4.** Идемпотентность: повторный прогон / reaper не делает второй merge; idempotency
|
||||
опирается на «SHA-в-main», а не на «любой merged PR».
|
||||
- **INV-5.** Обратная совместимость non-self (enduro): поведение merge/verify без изменений.
|
||||
|
||||
## 7. Заинтересованные стороны
|
||||
|
||||
- **Owner / Слава** — потребитель (видит кликабельные ссылки в карточке; доверие к merge-verify).
|
||||
- **Все проекты на инстансе** (enduro-trails) — общий `main`/очередь/БД; регресс орка = групповой риск.
|
||||
|
||||
## 8. Срочность
|
||||
|
||||
КРИТИКАЛ. Без FR-1/FR-4/FR-5 каждая новая задача с правкой `CHANGELOG.md` продолжает терять код
|
||||
предшественников (уже потеряны 067, 069). Ложно-зелёный merge-verify подрывает само ядро
|
||||
автономности конвейера.
|
||||
129
docs/work-items/ORCH-073/02-trz.md
Normal file
129
docs/work-items/ORCH-073/02-trz.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 02 — ТЗ: ORCH-073 — системный фикс эрозии main + восстановление кода 067/069
|
||||
|
||||
> ТЗ описывает ТРЕБУЕМОЕ ПОВЕДЕНИЕ и точки изменения. Выбор конкретного дизайна
|
||||
> (где именно резать docs-PR от code-PR, формат набора регресс-маркеров) — за архитектором (`06-adr`).
|
||||
> Запрещено комментировать ТЗ задним числом: если требование не годится — вернуть в Анализ.
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Роль в фиксе | FR |
|
||||
| --- | --- | --- |
|
||||
| `src/merge_gate.py` | `verify_merged_to_main`, `pr_already_merged`, `merge_pr`, новый регресс-гард | FR-1, FR-2, FR-3, FR-5 |
|
||||
| `src/stage_engine.py` | `_handle_merge_verify` (под-гейт `deploy → done`) — точка вызова FR-1/FR-5 | FR-1, FR-5 |
|
||||
| `src/config.py` | (опц.) настройки регресс-гарда: kill-switch + набор маркеров/таймаут | FR-5 |
|
||||
| `.gitattributes` (корень репо, новый) | `CHANGELOG.md merge=union` (+ опц. `docs/*.md merge=union`) | FR-4 |
|
||||
| `docs/architecture/README.md` | раздел merge-verify — обновить под новую семантику | AC-8 |
|
||||
| `CHANGELOG.md` | запись Unreleased | AC-8 |
|
||||
| `docs/work-items/ORCH-073/06-adr/` | ADR на новую семантику merge-verify + регресс-гард | AC-8 |
|
||||
|
||||
## 2. Требуемые изменения по коду
|
||||
|
||||
### FR-1 (G3, ядро) — `verify_merged_to_main` чинит семантику
|
||||
**Текущее (баг):** `src/merge_gate.py::verify_merged_to_main(repo, branch, sha)` возвращает `True`,
|
||||
если `pr_already_merged(...)` **ИЛИ** `git merge-base --is-ancestor <sha> origin/main`.
|
||||
OR-ветка `pr_already_merged` засчитывает docs-PR → ложно-зелёный.
|
||||
|
||||
**Требование:** подтверждение merge — **ТОЛЬКО** прямой факт «deployed commit является предком
|
||||
`origin/main`»:
|
||||
- после `git fetch origin main` выполнить `git merge-base --is-ancestor <deployed_sha> origin/main`;
|
||||
- `rc==0` → `True` (код в main), иначе → `False`.
|
||||
- `pr_already_merged` **НЕ может быть единственным/достаточным** условием `True`. Допустимо
|
||||
оставить PR-флаг только как **вспомогательный** сигнал (idempotency / диагностика), но он НЕ
|
||||
должен подтверждать merge при отсутствии SHA в main.
|
||||
- Пустой `sha` → неопределённо → `False` (fail-closed: alert + HOLD), как сейчас.
|
||||
- never-raise: любая git/HTTP-ошибка → `False` (INV-1).
|
||||
|
||||
### FR-2 (G2) — `pr_already_merged` различает code-PR и docs-PR
|
||||
**Текущее (баг):** `src/merge_gate.py::pr_already_merged` возвращает `True` за ЛЮБОЙ
|
||||
`merged==True` PR из `GET /pulls?state=all&head=<branch>` — включая авто docs-PR.
|
||||
|
||||
**Требование (на выбор архитектора, предпочтителен вариант «б»):**
|
||||
- **(а)** засчитывать merged только для PR, реально несущего код ветки: `base.ref==main`
|
||||
И `head.ref==<feature-branch>` (исключить docs/* ветки и docs-only PR); **или**
|
||||
- **(б, предпочтительно)** понизить роль `pr_already_merged` до **idempotency-guard**: единственный
|
||||
критерий «merged/done» — SHA-предок-`main` (FR-1); PR-флаги вспомогательны.
|
||||
- Поведение для non-self репо (enduro) не меняется (INV-5).
|
||||
- never-raise → `False` (консервативно).
|
||||
|
||||
### FR-3 (G2) — `merge_pr` реально сливает code-ветку
|
||||
**Требование:** `src/merge_gate.py::merge_pr` мержит ИМЕННО feature-PR с кодом (`base==main`,
|
||||
`head==<feature-branch>`), а не полагается на docs-PR. После merge — обязательная верификация
|
||||
по FR-1 (SHA в main) как единственный источник истины. Merge только через Gitea PR-merge API,
|
||||
никогда push/force-push в `main` (INV-2).
|
||||
|
||||
### FR-5 (G3 регресс-гард, защита навсегда) — sanity-проверка целостности main
|
||||
**Требование:** перед фиксацией `done` (в `_handle_merge_verify`, ПОСЛЕ зелёного
|
||||
`check_deploy_status`, до `update_task_stage`):
|
||||
1. Подтвердить FR-1 (deployed SHA — предок `origin/main`).
|
||||
2. (опц., по дизайну) Проверить, что в `origin/main` присутствует **набор маркеров** ключевых
|
||||
функций недавно-merged задач (regression marker set) — merge не уменьшил его.
|
||||
3. При откате соседнего кода / отсутствии маркера → **alert** «main regressed: code of <prev
|
||||
tasks> missing» (Telegram + Plane), задача **НЕ `done`** (HOLD), как ветка not-merged в ORCH-071.
|
||||
- Реакция — **ALERT-only + HOLD**, без авто-отката на `development` (это инфра-дефект, не код-фолт).
|
||||
- never-raise (INV-1); kill-switch (как `merge_verify_enabled`); условность только для self-hosting
|
||||
/ `merge_verify_repos` (INV-5).
|
||||
- Набор маркеров — конфигурируемый/декларативный (например, в `src/config.py` или рядом), чтобы
|
||||
следующие задачи могли его расширять. Точный формат — за архитектором.
|
||||
|
||||
### FR-4 (G2/G4 корень) — `.gitattributes` с `merge=union`
|
||||
**Требование:** в корне репо завести `.gitattributes`:
|
||||
```
|
||||
CHANGELOG.md merge=union
|
||||
# опционально для append-only документов:
|
||||
# docs/**/*.md merge=union # ВНИМАНИЕ: union НЕ годится для файлов, где правки
|
||||
# переписывают строки — применять только к append-only
|
||||
```
|
||||
- `merge=union` встроен в git (драйвер по умолчанию), доп. конфиг хоста не требуется — но
|
||||
проверить, что атрибут реально применяется в worktree агентов (`git check-attr merge CHANGELOG.md`).
|
||||
- Эффект: при `auto_rebase_onto_main` правки `## [Unreleased]` авто-сливаются (обе записи
|
||||
сохраняются) без конфликта → ветка не откатывается в `development` и не затирает соседний код.
|
||||
|
||||
## 3. Изменения API
|
||||
|
||||
- **Внешних HTTP API оркестратора (`src/main.py` endpoints) НЕ менять.**
|
||||
- Внутренние сигнатуры:
|
||||
- `verify_merged_to_main(repo, branch, sha) -> bool` — семантика меняется, сигнатура сохраняется.
|
||||
- `pr_already_merged(repo, branch) -> bool` — семантика/назначение уточняется.
|
||||
- `merge_pr(repo, branch) -> tuple[bool, str]` — поведение уточняется (фильтр code-PR).
|
||||
- (опц.) новая функция регресс-гарда в `merge_gate.py` — `tuple[bool, str]`/`bool`, never-raise.
|
||||
- `GET /queue` `merge_verify_status()` — допустимо дополнить счётчиком регресс-алертов (read-only,
|
||||
не источник истины).
|
||||
- Внешние вызовы Gitea — те же эндпоинты (`/pulls`, `/pulls/{index}/merge`).
|
||||
|
||||
## 4. Изменения схемы БД
|
||||
|
||||
- **НЕТ.** Схема БД (`src/db.py`) не трогается (Не-цель). Регресс-гард опирается на git/`origin/main`,
|
||||
не на новые таблицы.
|
||||
|
||||
## 5. Требования к новым/изменённым QG checks
|
||||
|
||||
- **Новых зарегистрированных QG-checks не вводить.** Логика остаётся **под-гейтом** в
|
||||
`advance_stage` (`_handle_merge_verify`), как ORCH-071 — не новый элемент реестра `QG_CHECKS`.
|
||||
- Реестр `QG_CHECKS`, `check_deploy_status`, `_parse_deploy_status`, merge-gate
|
||||
(`check_branch_mergeable`), image-freshness — **без изменений**.
|
||||
|
||||
## 6. Конфигурация (`src/config.py` / `.env.example`)
|
||||
|
||||
- Существующие `merge_verify_enabled` (kill-switch, дефолт `true`), `merge_verify_repos` (пусто →
|
||||
только self-hosting), `merge_pr_timeout_s`, `merge_verify_timeout_s` — переиспользовать.
|
||||
- (опц., по дизайну) новые: kill-switch регресс-гарда и декларация набора маркеров. Дефолты —
|
||||
безопасные (для non-self — no-op). Любой новый ключ задокументировать в `.env.example`.
|
||||
|
||||
## 7. Артефакты pipeline, которые должны быть созданы/обновлены
|
||||
|
||||
- `docs/work-items/ORCH-073/06-adr/ADR-001-*.md` — решение по новой семантике merge-verify
|
||||
(FR-1/FR-2/FR-3) + регресс-гард (FR-5) + `.gitattributes` (FR-4).
|
||||
- `docs/architecture/README.md` — обновить раздел «Merge-в-main + пост-деплой верификация»
|
||||
(ORCH-071) под FR-1 (SHA как единственный критерий) и добавить регресс-гард FR-5.
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]`.
|
||||
- `docs/work-items/ORCH-073/10-tech-risks.md`, `12-review.md`, `13-test-report.md`,
|
||||
`14-deploy-log.md`, `15-staging-log.md` — по ходу конвейера.
|
||||
- `04-test-plan.yaml` (этот пакет) — реализовать тесты в `tests/`.
|
||||
|
||||
## 8. Аудит G4 (зафиксировать в ADR / 06-adr)
|
||||
|
||||
Зафиксировать подтверждённую причину docs-only merge: у feature-ветки 067/069 в `main` попадали
|
||||
только авто docs-PR (staging-log / deploy-log / CLAUDE.md / CHANGELOG), а code-PR не сливался,
|
||||
при этом `pr_already_merged` засчитывал docs-PR → merge-verify ложно `CONFIRMED` → `done`.
|
||||
Корень устранён FR-1+FR-2+FR-3. Восстановление кода (G1) уже выполнено restore-PR #76 —
|
||||
подтвердить маркеры в `origin/main` (AC-1).
|
||||
77
docs/work-items/ORCH-073/03-acceptance-criteria.md
Normal file
77
docs/work-items/ORCH-073/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 03 — Критерии приёмки: ORCH-073
|
||||
|
||||
Каждый критерий — однозначный PASS/FAIL. Reviewer/Tester проверяют буквально.
|
||||
|
||||
## AC-1 — Код 067/069/071 одновременно в main (G1)
|
||||
`origin/main` содержит **одновременно**: `plane_issue_link` + кликабельный заголовок (ORCH-067),
|
||||
`qg0_title_max` (ORCH-069), `verify_merged_to_main` (ORCH-071).
|
||||
- **PASS:** все три маркера присутствуют, счётчики > 0:
|
||||
`git grep -c plane_issue_link origin/main -- src/notifications.py` > 0;
|
||||
`git grep -c qg0_title_max origin/main -- src/` > 0;
|
||||
`git grep -c verify_merged_to_main origin/main -- src/merge_gate.py` > 0.
|
||||
- **FAIL:** хотя бы один маркер == 0.
|
||||
|
||||
## AC-2 — `verify_merged_to_main` подтверждает merge ТОЛЬКО по SHA-в-main (FR-1)
|
||||
`verify_merged_to_main(repo, branch, sha)` возвращает `True` **только** когда `sha` — реальный
|
||||
предок `origin/main`.
|
||||
- **PASS:** unit-тест: `sha` НЕ в `main` → `False`, **даже если** существует merged docs-PR той же
|
||||
ветки (mock `pr_already_merged`/Gitea возвращает merged docs-PR). `sha` в `main` → `True`.
|
||||
- **FAIL:** функция возвращает `True` при `sha` не в `main` из-за merged docs-PR.
|
||||
|
||||
## AC-3 — Воспроизведение исходного бага → НЕ done + alert (FR-1/FR-2)
|
||||
Задача с merged **docs-PR**, но БЕЗ merged **code-PR** (SHA не в main): merge-verify НЕ
|
||||
`CONFIRMED`.
|
||||
- **PASS:** `_handle_merge_verify` возвращает HOLD (intervened) → задача остаётся на `deploy`,
|
||||
НЕ `done`, отправлен alert «not merged» (Telegram + Plane `set_issue_blocked`). Mock
|
||||
воспроизводит сценарий ORCH-067/069.
|
||||
- **FAIL:** задача доходит до `done` / нет alert.
|
||||
|
||||
## AC-4 — `.gitattributes CHANGELOG.md merge=union` (FR-4)
|
||||
В корне репо есть `.gitattributes` с `CHANGELOG.md merge=union`.
|
||||
- **PASS:** файл существует, `git check-attr merge CHANGELOG.md` → `merge: union`; тест: два
|
||||
последовательных ребейза/слияния с правкой `## [Unreleased]` НЕ дают конфликта, обе записи
|
||||
сохранены в результирующем `CHANGELOG.md`.
|
||||
- **FAIL:** атрибут отсутствует/не применяется ИЛИ возникает конфликт-маркер при ребейзе.
|
||||
|
||||
## AC-5 — Регресс-гард ловит откат соседнего кода (FR-5)
|
||||
После деплоя `main` без маркера ранее-merged задачи → alert, задача НЕ `done`.
|
||||
- **PASS:** тест: симуляция `main`, где deployed SHA есть, но набор маркеров уменьшился (или
|
||||
deployed SHA НЕ предок main) → `_handle_merge_verify` HOLD + alert «main regressed», НЕ `done`.
|
||||
- **FAIL:** регресс соседнего кода не пойман, задача `done`.
|
||||
|
||||
## AC-6 — Happy-path без ложных alert (INV-5 / AC-5 ТЗ)
|
||||
Код реально в `main` (deployed SHA — предок `origin/main`) → задача `done` штатно, без ложного
|
||||
alert; для non-self репо (enduro) merge/verify без изменений.
|
||||
- **PASS:** тест happy-path: SHA в main → `verify_merged_to_main`=`True`, `_handle_merge_verify`
|
||||
возвращает «advance» (не intervened); non-self репо → под-гейт no-op.
|
||||
- **FAIL:** ложный alert на корректном merge ИЛИ изменение поведения для enduro.
|
||||
|
||||
## AC-7 — Идемпотентность по SHA-в-main (INV-4)
|
||||
Повторный прогон/reaper уже-слитой задачи (SHA в main) → no-op, без второго merge.
|
||||
- **PASS:** тест: re-drive задачи с SHA-в-main → `merge_pr` no-op («already-merged»/idempotent),
|
||||
второго Gitea POST merge нет; задача остаётся `done`.
|
||||
- **FAIL:** второй merge / дубликат / ошибка.
|
||||
|
||||
## AC-8 — Документация и тесты обновлены (правило агентов §2/§6)
|
||||
- **PASS:** обновлены `CHANGELOG.md` (Unreleased), `docs/architecture/README.md` (раздел
|
||||
merge-verify под FR-1 + регресс-гард FR-5), создан ADR в `docs/work-items/ORCH-073/06-adr/`;
|
||||
pytest зелёный (`pytest tests/ -q`).
|
||||
- **FAIL:** доки/ADR не обновлены ИЛИ pytest красный.
|
||||
|
||||
## AC-9 — G4 аудит задокументирован
|
||||
Причина docs-only merge (code-PR не слит, `pr_already_merged` засчитал docs-PR) зафиксирована в
|
||||
ADR/06-adr, корень устранён (FR-1+FR-2+FR-3).
|
||||
- **PASS:** ADR содержит раздел «Root cause / G4 audit» с воспроизведением и устранением.
|
||||
- **FAIL:** аудит отсутствует.
|
||||
|
||||
## AC-10 — Воспроизведение на staging «исправлено навсегда» (G3/AC-9 ТЗ)
|
||||
2 задачи, обе с правкой `CHANGELOG.md`, прогнаны через staging → обе доезжают в `main` без потери
|
||||
кода друг друга.
|
||||
- **PASS:** зафиксировано в `15-staging-log.md`: оба набора маркеров присутствуют в `main` после
|
||||
обоих merge; ни одна правка CHANGELOG не вызвала конфликт/откат.
|
||||
- **FAIL:** код одной задачи затёрт другой ИЛИ конфликт CHANGELOG.
|
||||
|
||||
## AC-11 — self-hosting safety сохранена (INV-2/INV-3)
|
||||
- **PASS:** merge только через PR-API (без force-push в `main`); прод-контейнер не падал в рамках
|
||||
задачи; ручной `Confirm Deploy` сохранён.
|
||||
- **FAIL:** force-push в main / рестарт прод-контейнера в рамках merge / обход Confirm Deploy.
|
||||
117
docs/work-items/ORCH-073/04-test-plan.yaml
Normal file
117
docs/work-items/ORCH-073/04-test-plan.yaml
Normal file
@@ -0,0 +1,117 @@
|
||||
work_item: ORCH-073
|
||||
title: "CRIT: эрозия main — системный фикс merge-verify + восстановление кода 067/069"
|
||||
notes: >
|
||||
Покрытие FR-1..FR-5 / AC-1..AC-11. Все верификаторы — never-raise (INV-1):
|
||||
при ошибке git/HTTP → False (fail-closed), не падение. Gitea/git вызовы мокаются
|
||||
(monkeypatch httpx + subprocess), как в существующих тестах merge_gate/stage_engine.
|
||||
Тесты регресс-гарда и .gitattributes используют временный git-репозиторий (tmp_path).
|
||||
|
||||
tests:
|
||||
# ---- FR-1: verify_merged_to_main — SHA-в-main как единственный критерий ----
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "verify_merged_to_main: sha — предок origin/main → True (happy-path, AC-6)."
|
||||
module: tests/test_orch073_merge_verify.py
|
||||
expected: PASS
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "verify_merged_to_main: sha НЕ предок main И существует merged docs-PR ветки → False (баг 067/069, AC-2)."
|
||||
module: tests/test_orch073_merge_verify.py
|
||||
expected: PASS
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "verify_merged_to_main: пустой sha → False (неопределённо, fail-closed)."
|
||||
module: tests/test_orch073_merge_verify.py
|
||||
expected: PASS
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "verify_merged_to_main: git fetch/merge-base бросает исключение → False (never-raise, INV-1)."
|
||||
module: tests/test_orch073_merge_verify.py
|
||||
expected: PASS
|
||||
|
||||
# ---- FR-2: pr_already_merged различает code-PR / docs-PR ----
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "pr_already_merged/идентификация PR: merged docs-PR (head=docs/*, base=main) НЕ засчитывается как merge кода ветки."
|
||||
module: tests/test_orch073_pr_classify.py
|
||||
expected: PASS
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "merged code-PR (head=<feature-branch>, base=main) корректно распознаётся как code-merge."
|
||||
module: tests/test_orch073_pr_classify.py
|
||||
expected: PASS
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "pr_already_merged: HTTP-ошибка/не-200 → False (never-raise, консервативно)."
|
||||
module: tests/test_orch073_pr_classify.py
|
||||
expected: PASS
|
||||
|
||||
# ---- FR-3: merge_pr сливает именно code-ветку ----
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "merge_pr выбирает open PR с head==<feature-branch> и base==main (не docs/*), вызывает Gitea POST merge."
|
||||
module: tests/test_orch073_merge_pr.py
|
||||
expected: PASS
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "merge_pr: нет open code-PR → (False, 'no open PR'); никогда не push/force-push main (INV-2)."
|
||||
module: tests/test_orch073_merge_pr.py
|
||||
expected: PASS
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "merge_pr идемпотентен: уже-слитый code-PR (SHA в main) → no-op, без второго POST merge (AC-7/INV-4)."
|
||||
module: tests/test_orch073_merge_pr.py
|
||||
expected: PASS
|
||||
|
||||
# ---- FR-4: .gitattributes CHANGELOG.md merge=union ----
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: ".gitattributes в корне репо содержит 'CHANGELOG.md merge=union'; git check-attr подтверждает driver=union (AC-4)."
|
||||
module: tests/test_orch073_gitattributes.py
|
||||
expected: PASS
|
||||
- id: TC-12
|
||||
type: integration
|
||||
description: "Во временном git-репо два ребейза/слияния с правкой '## [Unreleased]' НЕ дают конфликта; обе записи в CHANGELOG сохранены (AC-4)."
|
||||
module: tests/test_orch073_gitattributes.py
|
||||
expected: PASS
|
||||
|
||||
# ---- FR-5: регресс-гард целостности main + интеграция в _handle_merge_verify ----
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "_handle_merge_verify: SHA в main И маркеры на месте → return False (advance к done, happy-path AC-6)."
|
||||
module: tests/test_orch073_regression_guard.py
|
||||
expected: PASS
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "_handle_merge_verify: SHA НЕ в main (docs-only merge) → return True (HOLD), alert + set_issue_blocked, НЕ done (AC-3)."
|
||||
module: tests/test_orch073_regression_guard.py
|
||||
expected: PASS
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "Регресс-гард: deployed SHA есть, но набор маркеров ранее-merged задач уменьшился → HOLD + alert 'main regressed', НЕ done (AC-5)."
|
||||
module: tests/test_orch073_regression_guard.py
|
||||
expected: PASS
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "_handle_merge_verify: внутренняя ошибка верификатора → HOLD + alert, без проброса исключения в advance_stage (never-raise, INV-1)."
|
||||
module: tests/test_orch073_regression_guard.py
|
||||
expected: PASS
|
||||
|
||||
# ---- Условность / обратная совместимость ----
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "merge_verify_applies: non-self репо (enduro) или kill-switch off → под-гейт no-op, поведение merge/verify без изменений (AC-6/INV-5)."
|
||||
module: tests/test_orch073_conditionality.py
|
||||
expected: PASS
|
||||
- id: TC-18
|
||||
type: unit
|
||||
description: "Регресс-гард уважает kill-switch (merge_verify_enabled=False) → no-op; для non-self → no-op (INV-5)."
|
||||
module: tests/test_orch073_conditionality.py
|
||||
expected: PASS
|
||||
|
||||
# ---- Регресс существующего поведения ----
|
||||
- id: TC-19
|
||||
type: integration
|
||||
description: "Существующие тесты merge_gate/stage_engine (ORCH-065/071) остаются зелёными; полный pytest tests/ -q green (AC-8)."
|
||||
module: tests/
|
||||
expected: PASS
|
||||
@@ -0,0 +1,214 @@
|
||||
# ADR-001 (ORCH-073): SHA-в-main как единственный критерий merge-verify + регресс-гард + `.gitattributes`
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-08
|
||||
- **Задача:** ORCH-073 (BUG CRITICAL — эрозия `main`)
|
||||
- **Связь:** усиливает/чинит ORCH-071 (merge-verify под-гейт). Сквозной аналог — `docs/architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md` (amends adr-0013).
|
||||
- **Источники:** `01-brd.md` (root-cause git-аудит 08.06), `02-trz.md` (FR-1…FR-5), `03-acceptance-criteria.md` (AC-1…AC-11).
|
||||
|
||||
## Контекст
|
||||
|
||||
Код «задеплоенных» и переведённых в `done` задач **ORCH-067** (`plane_issue_link`, кликабельные
|
||||
ссылки, tracker bump) и **ORCH-069** (`qg0_title_max`) физически отсутствовал в `origin/main`,
|
||||
хотя обе прошли весь конвейер, Confirm Deploy, merge-verify `CONFIRMED` и стали `done`. В `main`
|
||||
попадали только их **docs-коммиты** (staging/deploy-логи через отдельные авто docs-PR), но НЕ
|
||||
код feature-веток. Внешнее проявление (нашёл Слава, 08.06): в карточке Telegram сырой номер
|
||||
задачи вместо кликабельной ссылки — код ссылок есть в ветке ORCH-067, но не в `main`.
|
||||
|
||||
### Root cause (G4 audit) — подтверждён git-аудитом, НЕ гипотеза
|
||||
|
||||
1. **`verify_merged_to_main` подтверждает merge по ложному признаку.** Возвращает `True`, если
|
||||
`pr_already_merged(repo, branch)` **ЛИБО** `git merge-base --is-ancestor <sha> origin/main`.
|
||||
OR-ветка `pr_already_merged` — и есть дыра.
|
||||
2. **`pr_already_merged` засчитывает ЛЮБОЙ merged PR.** `GET /pulls?state=all&head=<branch>` и
|
||||
`True`, если **хоть один** PR `merged==True`. Параметр `head` у Gitea для одиночной строки-ветки
|
||||
фильтрует ненадёжно → в выборку попадают авто docs-PR (staging/deploy-логи) с других веток
|
||||
(`docs/*`). Сливается docs-PR → `pr_already_merged`=`True` → `verify_merged_to_main`=`True` →
|
||||
merge-verify `CONFIRMED` → `done`, хотя **code-PR НЕ слит**. Ложно-зелёный.
|
||||
3. **CHANGELOG-ребейзы — вторичный усилитель.** `auto_rebase_onto_main` при конфликте
|
||||
`CHANGELOG.md` откатывает `deploy-staging → development`; повторный ребейз ветки от старого
|
||||
`main` несёт устаревшие версии соседних файлов, которые при merge тихо затирают код-сосед
|
||||
(фантом-эффект как в ORCH-071, без конфликт-маркеров).
|
||||
|
||||
**G1 (восстановление кода) выполнено вручную** restore-PR #76 — `git grep` подтверждает в
|
||||
`origin/main` одновременно `plane_issue_link` (8), `qg0_title_max` (3+2), `verify_merged_to_main`
|
||||
(4). ORCH-073 фиксирует это в AC-1 и устраняет корень навсегда (FR-1…FR-5).
|
||||
|
||||
## Решение
|
||||
|
||||
Меняется **семантика merge-verify** (под-гейт ребра `deploy → done`, врезка `_handle_merge_verify`
|
||||
в `advance_stage`, введён ORCH-071). `STAGE_TRANSITIONS`, реестр `QG_CHECKS`,
|
||||
`check_deploy_status`/`_parse_deploy_status`, merge-gate (`check_branch_mergeable`),
|
||||
image-freshness, схема БД (`src/db.py`) — **НЕ меняются**. Внешние HTTP-эндпоинты `src/main.py` —
|
||||
**НЕ меняются**.
|
||||
|
||||
### Р-1 (FR-1, ядро) — `verify_merged_to_main`: SHA-в-main — единственный критерий
|
||||
|
||||
Подтверждение merge — **ТОЛЬКО** прямой факт «deployed commit является предком `origin/main`»:
|
||||
|
||||
```
|
||||
verify_merged_to_main(repo, branch, sha) -> bool:
|
||||
if not sha: # пустой SHA -> неопределённо
|
||||
log warning; return False # fail-closed (alert + HOLD)
|
||||
git fetch origin main (timeout merge_verify_timeout_s)
|
||||
rc = git merge-base --is-ancestor <sha> origin/main
|
||||
return rc == 0
|
||||
```
|
||||
|
||||
- **OR-ветка `pr_already_merged` удаляется** из `verify_merged_to_main`. PR-флаг больше **не
|
||||
подтверждает** merge.
|
||||
- Пустой `sha` → `False` (fail-closed: alert + HOLD), как сейчас.
|
||||
- never-raise: любая git-ошибка → `False` (INV-1) — фейл-клозед для `done`.
|
||||
|
||||
> Дизайн-выбор: вариант (б) из ТЗ §2 FR-2 — единственный источник истины «merged/done» — это
|
||||
> SHA-в-main. PR-флаги остаются только как **idempotency-guard** в `merge_pr` (Р-3), не как
|
||||
> подтверждение.
|
||||
|
||||
### Р-2 (FR-2/G2) — `pr_already_merged`: различает code-PR и docs-PR
|
||||
|
||||
`pr_already_merged` понижается до **idempotency-guard для `merge_pr`** (не источник истины для
|
||||
`done`). Но guard обязан быть **корректным**: «слит ли именно code-PR ЭТОЙ ветки», иначе merged
|
||||
docs-PR заставил бы `merge_pr` ошибочно сделать no-op и пропустить реальный merge кода.
|
||||
Поэтому в цикле явный фильтр (НЕ полагаться на ненадёжный query-параметр `head`):
|
||||
|
||||
```
|
||||
for pr in resp.json():
|
||||
if pr.merged is True
|
||||
and pr.head.ref == branch # код именно этой feature-ветки
|
||||
and pr.base.ref == "main": # таргет — main, не docs-база
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
- Исключает авто docs-PR (другой `head.ref`, напр. `docs/*`) и PR на не-`main` базу.
|
||||
- never-raise → `False` (консервативно).
|
||||
- Поведение для non-self репо (enduro) не меняется (INV-5) — `merge_pr`/verify для них как раньше.
|
||||
|
||||
### Р-3 (FR-3/G2) — `merge_pr`: сливает именно code-ветку
|
||||
|
||||
`merge_pr` уже выбирает открытый PR по `head.ref==branch`; добавляется фильтр `base.ref=="main"`
|
||||
при выборе PR (защита от слияния PR на чужую базу). Idempotency-guard `pr_already_merged` (Р-2,
|
||||
теперь корректный) перед merge оставляем — повторный прогон не делает второй POST. Merge —
|
||||
ТОЛЬКО Gitea `POST /pulls/{index}/merge`, никогда push/force-push в `main` (INV-2). После merge
|
||||
единственный источник истины «слилось» — FR-1 (SHA-в-main), его проверяет `_handle_merge_verify`.
|
||||
|
||||
### Р-4 (FR-5/G5) — регресс-гард целостности `main` (защита навсегда)
|
||||
|
||||
Новая детерминированная (no-LLM) функция в `merge_gate.py`, вызывается в `_handle_merge_verify`
|
||||
**ПОСЛЕ** подтверждённого SHA-в-main (Р-1) и **ДО** `update_task_stage(done)`:
|
||||
|
||||
```
|
||||
check_main_regression(repo, branch) -> tuple[bool, str]
|
||||
# ok=True -> регресса нет (набор маркеров цел) -> пропустить к done
|
||||
# ok=False -> маркер отсутствует -> "main regressed: <task/marker> missing"
|
||||
```
|
||||
|
||||
**Декларативный набор маркеров** — константа в `merge_gate.py` (append-only, расширяется каждой
|
||||
будущей задачей; НЕ БД, НЕ Plane — Не-цель):
|
||||
|
||||
```python
|
||||
MAIN_REGRESSION_MARKERS = [
|
||||
# (task, marker_substring, path)
|
||||
("ORCH-067", "plane_issue_link", "src/notifications.py"),
|
||||
("ORCH-069", "qg0_title_max", "src/config.py"),
|
||||
("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
|
||||
("ORCH-073", "check_main_regression", "src/merge_gate.py"),
|
||||
]
|
||||
```
|
||||
|
||||
Проверка (в worktree после `git fetch origin main`): для каждого маркера
|
||||
`git grep -c <marker> origin/main -- <path>`; счётчик `0` → регресс.
|
||||
|
||||
- **Реакция при регрессе: ALERT-only + HOLD** (`set_issue_blocked` + Telegram + Plane-коммент
|
||||
«main regressed: code of `<task>` missing»), задача **НЕ `done`**, остаётся на `deploy`. БЕЗ
|
||||
авто-отката на `development` (это инфра-дефект, не код-фолт), симметрично not-merged ветке
|
||||
ORCH-071.
|
||||
- **Fail-OPEN на инфра-ошибке грепа** (намеренный trade-off): любая git/OS-ошибка самого грепа →
|
||||
`(True, "guard inconclusive: …")` → НЕ блокировать `done`. Обоснование: первичный фейл-клозед
|
||||
гейт — это SHA-в-main (Р-1); вторичный marker-grep не должен давать ложный HOLD на git-сбое.
|
||||
«Регресс» утверждается только при **детерминированном `count==0`**, не при «не смог определить».
|
||||
- never-raise (INV-1). Kill-switch — новый `regression_guard_enabled` (дефолт `true`,
|
||||
переиспользует область self-hosting через `merge_verify_applies`). Non-self репо — no-op (INV-5).
|
||||
|
||||
### Р-5 (FR-4/G4 корень) — `.gitattributes` с `merge=union`
|
||||
|
||||
В корне репозитория новый файл `.gitattributes`:
|
||||
|
||||
```
|
||||
CHANGELOG.md merge=union
|
||||
```
|
||||
|
||||
- `merge=union` — встроенный git-драйвер, доп. конфиг хоста не требуется; проверяется
|
||||
`git check-attr merge CHANGELOG.md` → `merge: union`.
|
||||
- Эффект: при `auto_rebase_onto_main` правки `## [Unreleased]` авто-сливаются (обе записи
|
||||
сохраняются) без конфликт-маркера → ветка не откатывается в `development` и не тащит устаревшие
|
||||
версии соседних файлов.
|
||||
- **Решено НЕ добавлять `docs/**/*.md merge=union`:** union годится только для строго
|
||||
append-only файлов; docs-артефакты (README, ADR, internals) регулярно **переписываются**
|
||||
построчно — union там тихо задублировал бы строки. Ограничиваемся `CHANGELOG.md`.
|
||||
- Оговорка о самозагрузке: задача, ВПЕРВЫЕ вносящая `.gitattributes`, при собственном ребейзе
|
||||
ещё не получает эффект union (атрибут попадёт в `main` только после её merge). Это допустимо —
|
||||
гард действует для всех последующих задач.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
| Ключ | Дефолт | Назначение |
|
||||
|---|---|---|
|
||||
| `merge_verify_enabled` (есть) | `true` | kill-switch всего под-гейта |
|
||||
| `merge_verify_repos` (есть) | `""` | CSV; пусто → только self-hosting |
|
||||
| `merge_pr_timeout_s` / `merge_verify_timeout_s` (есть) | `60` | таймауты Gitea/git |
|
||||
| `regression_guard_enabled` (новый) | `true` | kill-switch регресс-гарда (Р-4); non-self → no-op |
|
||||
|
||||
Новый ключ задокументировать в `.env.example`. Дефолты безопасны (для non-self — no-op).
|
||||
|
||||
## Сигнатуры (внутренние; внешний API не меняется)
|
||||
|
||||
- `verify_merged_to_main(repo, branch, sha) -> bool` — семантика меняется (Р-1), сигнатура та же.
|
||||
- `pr_already_merged(repo, branch) -> bool` — назначение/фильтр уточняются (Р-2), сигнатура та же.
|
||||
- `merge_pr(repo, branch) -> tuple[bool, str]` — фильтр `base==main` (Р-3), сигнатура та же.
|
||||
- `check_main_regression(repo, branch) -> tuple[bool, str]` — **новая**, never-raise, fail-open.
|
||||
- `merge_verify_status()` — допустимо дополнить счётчиком регресс-алертов (read-only, не источник истины).
|
||||
|
||||
## Инварианты
|
||||
|
||||
- **INV-1** never-raise: ошибка верификации → alert/HOLD, не падение конвейера.
|
||||
- **INV-2** self-hosting safety: прод 8500 не падает/не рестартится в рамках merge; merge только
|
||||
Gitea PR-API, без force-push в `main`.
|
||||
- **INV-3** ручной `Confirm Deploy` (ORCH-059) сохранён.
|
||||
- **INV-4** идемпотентность опирается на «SHA-в-main», а не на «любой merged PR».
|
||||
- **INV-5** обратная совместимость non-self (enduro): merge/verify/регресс-гард — no-op.
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
|
||||
1. **Оставить `pr_already_merged` как со-критерий verify, но фильтровать по `head/base`** —
|
||||
отклонено: PR-флаг всё равно слабее факта «SHA в main» (PR можно слить и тут же откатить
|
||||
ребейзом-соседом). Единственный надёжный критерий — предок-`main`. PR-флаг → только idempotency.
|
||||
2. **`docs/**/*.md merge=union`** — отклонено (см. Р-5): тихая дубликация строк в переписываемых
|
||||
доках.
|
||||
3. **Регресс-гард с авто-откатом на `development`** — отклонено: регресс соседнего кода —
|
||||
инфра-дефект merge, не код-фолт текущей задачи; реакция ALERT-only + HOLD (как ORCH-021/071).
|
||||
4. **Хранить набор маркеров в БД/Plane** — отклонено (Не-цель «не менять схему БД/Plane»);
|
||||
декларативная append-only константа в коде проще и версионируется вместе с фиксом.
|
||||
5. **Fail-closed на marker-grep** — отклонено: дало бы ложный HOLD при git-сбое; первичный
|
||||
фейл-клозед — SHA-в-main (Р-1), marker-grep вторичен → fail-open.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Плюс:** невозможно «`done` + прод задеплоен, а code-PR не в `main`» — единственный критерий
|
||||
`done` теперь «SHA-в-main». Ложно-зелёный по docs-PR устранён в корне (Р-1+Р-2+Р-3).
|
||||
- **Плюс:** CHANGELOG-конфликты больше не откатывают ветку и не тащат устаревший код-сосед (Р-5).
|
||||
- **Плюс:** регресс-гард ловит откат соседнего кода даже если SHA-в-main прошёл (Р-4).
|
||||
- **Минус:** при недоступной Gitea/git verify консервативно `False` → возможен ложный HOLD+alert
|
||||
(снимается повтором; fail-closed для `done` приоритетен). Регресс-гард при git-сбое наоборот
|
||||
fail-open (не блокирует) — осознанный trade-off, SHA-в-main остаётся первичным гейтом.
|
||||
- **Минус:** набор маркеров требует дисциплины — каждая значимая задача дописывает свой маркер
|
||||
(иначе гард его не защитит). Документируется в `CLAUDE.md`/README.
|
||||
|
||||
## Связи
|
||||
|
||||
- Amends: `docs/architecture/adr/adr-0013-merge-verify-gate.md` (ORCH-071) — меняет критерий verify.
|
||||
- Сквозной: `docs/architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md`.
|
||||
- Постмортем: `docs/history/LESSONS_2026-06-08_phantom-merge.md`, runbook
|
||||
`docs/operations/PHANTOM_MERGE_RUNBOOK.md`.
|
||||
- AC: AC-1 (G1 markers), AC-2/AC-3 (Р-1/Р-2), AC-4 (Р-5), AC-5 (Р-4), AC-6 (happy-path),
|
||||
AC-7 (idempotency), AC-8/AC-9 (docs+audit), AC-10 (staging), AC-11 (self-hosting safety).
|
||||
32
docs/work-items/ORCH-073/07-infra-requirements.md
Normal file
32
docs/work-items/ORCH-073/07-infra-requirements.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 07 — Инфра-требования: ORCH-073
|
||||
|
||||
## Топология
|
||||
**Без изменений.** Один сервер (mva154), prod `orchestrator` (8500), staging
|
||||
`orchestrator-staging` (8501), общая SQLite, общая очередь. Новых контейнеров/портов/сервисов нет.
|
||||
|
||||
## Git / worktree
|
||||
- Новый корневой файл **`.gitattributes`** (`CHANGELOG.md merge=union`). Драйвер `union` —
|
||||
встроенный в git, **доп. конфигурация хоста НЕ требуется**.
|
||||
- Проверка применения в worktree агентов: `git check-attr merge CHANGELOG.md` → `merge: union`.
|
||||
Атрибут действует при 3-way merge/rebase, когда `.gitattributes` присутствует в дереве
|
||||
(`auto_rebase_onto_main` выполняет `git rebase origin/main` в per-branch worktree).
|
||||
- Самозагрузка: первая задача с `.gitattributes` своего ребейза не ускоряет (атрибут попадёт в
|
||||
`main` после её merge); эффект — для последующих задач. Допустимо.
|
||||
- Регресс-гард (`check_main_regression`) использует уже существующий per-branch worktree
|
||||
(`ensure_worktree` + `git fetch origin main` + `git grep origin/main`). Новых клонов/worktree нет.
|
||||
|
||||
## Сеть / внешние интеграции
|
||||
- Те же Gitea-эндпоинты: `GET /pulls`, `POST /pulls/{index}/merge`. Новых внешних вызовов нет.
|
||||
- Telegram/Plane — существующие хелперы alert (`send_telegram`, `set_issue_blocked`,
|
||||
`plane_add_comment`). Новых интеграций нет.
|
||||
|
||||
## Деплой self (self-hosting safety)
|
||||
- Прод-контейнер `orchestrator` (8500) **НЕ рестартить/не ронять** в рамках задачи.
|
||||
- Обязательный staging-гейт (8501) перед прод-деплоем; прод-деплой — только переводом на
|
||||
`Confirm Deploy` (ORCH-059). Ручной гейт не меняется.
|
||||
- Merge — только Gitea PR-API, без force-push в `main`.
|
||||
|
||||
## Конфигурация (хост `.env` / `.env.example`)
|
||||
- Новый ключ `regression_guard_enabled` (дефолт `true`) — задокументировать в `.env.example`.
|
||||
- Существующие `merge_verify_enabled`/`merge_verify_repos`/`merge_pr_timeout_s`/
|
||||
`merge_verify_timeout_s` — переиспользуются, без изменений значений.
|
||||
23
docs/work-items/ORCH-073/08-data-requirements.md
Normal file
23
docs/work-items/ORCH-073/08-data-requirements.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 08 — Требования к данным/схеме БД: ORCH-073
|
||||
|
||||
## Схема БД
|
||||
**Без изменений.** `src/db.py` не трогается (Не-цель BRD §5, ТЗ §4). Новых таблиц/колонок/
|
||||
миграций нет.
|
||||
|
||||
## Источник истины merge-verify
|
||||
- Подтверждение `done` опирается **только на git** (`origin/main`: `git merge-base
|
||||
--is-ancestor <sha> origin/main`), НЕ на состояние БД и НЕ на Plane-статусы.
|
||||
- Регресс-гард (`check_main_regression`) опирается на `git grep origin/main` по декларативному
|
||||
набору маркеров — **не на БД**.
|
||||
- Набор маркеров `MAIN_REGRESSION_MARKERS` — **append-only константа в коде** (`src/merge_gate.py`),
|
||||
версионируется вместе с фиксом. Сознательно НЕ в БД и НЕ в Plane (Не-цель).
|
||||
|
||||
## Состояние в БД (читается, не меняется)
|
||||
- `tasks.stage` — переходы через существующий `update_task_stage`/`advance_stage`; HOLD = задача
|
||||
остаётся на `deploy` (не записывается `done`). Семантика та же, что у ORCH-071.
|
||||
- Счётчики `_MERGE_VERIFY_COUNTERS` — **in-process**, не БД; read-only через `GET /queue`.
|
||||
Допустимо дополнить счётчиком регресс-алертов (наблюдаемость, не источник истины).
|
||||
|
||||
## Plane
|
||||
**Без изменений** (Не-цель). Используются существующие сеттеры (`set_issue_blocked`,
|
||||
`plane_add_comment`) для alert/HOLD. Новых статусов/маппингов нет.
|
||||
19
docs/work-items/ORCH-073/10-tech-risks.md
Normal file
19
docs/work-items/ORCH-073/10-tech-risks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 10 — Технические риски: ORCH-073
|
||||
|
||||
| # | Риск | Вероятность | Влияние | Митигация |
|
||||
|---|------|-------------|---------|-----------|
|
||||
| R-1 | **Ложный HOLD на сбое Gitea/git** — verify консервативно `False` при недоступности → задача не доходит до `done`, нужен повтор. | средняя | среднее | Осознанный fail-closed для `done` (приоритет: не дать ложно-зелёный). Снимается re-drive (reaper/reconciler/re-approve). Документировано в ADR «Последствия». |
|
||||
| R-2 | **`pr_already_merged` всё ещё ловит docs-PR** при иной структуре head/base в Gitea (cross-repo `owner:branch`). | низкая | высокое (возврат бага) | Явный фильтр в цикле `head.ref==branch И base.ref=="main"` (не полагаться на query-param). Тест AC-2/AC-3 мокает merged docs-PR и проверяет, что verify=`False`. |
|
||||
| R-3 | **Регресс-гард fail-open пропустит реальный регресс** во время git-сбоя грепа. | низкая | среднее | Первичный гейт `done` — SHA-в-main (fail-closed). Marker-grep вторичен; «регресс» — только при детерминированном `count==0`. Trade-off зафиксирован в ADR. |
|
||||
| R-4 | **Набор маркеров устаревает/неполный** — будущая задача не добавила свой маркер → гард её не защищает. | средняя | среднее | Append-only константа в коде + правило в `CLAUDE.md`/README «значимая задача дописывает маркер». Reviewer проверяет. Не регресс существующего поведения (только недозащита нового). |
|
||||
| R-5 | **`merge=union` тихо дублирует строки** при применении к не-append-only файлам. | низкая | среднее | Union строго ограничен `CHANGELOG.md`; `docs/**` под union НЕ ставится (решение Р-5 ADR). |
|
||||
| R-6 | **Самозагрузка `.gitattributes`** — первая задача не получает эффект union на своём ребейзе. | высокая (одноразово) | низкое | Принято: атрибут попадёт в `main` после merge ORCH-073, действует для последующих задач. Для самой ORCH-073 CHANGELOG-конфликт разрешается вручную при необходимости. |
|
||||
| R-7 | **Ложный «main regressed» при легитимном рефакторе**, переименовавшем маркер-функцию. | низкая | среднее | Маркеры выбираются как стабильные публичные имена; при намеренном переименовании задача обновляет `MAIN_REGRESSION_MARKERS` в том же PR (правило документации). |
|
||||
| R-8 | **Регресс на non-self репо (enduro)** из-за нового кода. | низкая | высокое | Вся врезка под `merge_verify_applies` (kill-switch + self-hosting scope); регресс-гард — отдельный `regression_guard_enabled`; non-self → no-op (INV-5). Тест AC-6 (enduro no-op). |
|
||||
| R-9 | **Self-hosting: рестарт/падение прода** при ошибке в merge_gate. | низкая | высокое (групповой риск) | never-raise контракт (INV-1); merge только PR-API без force-push; staging-гейт обязателен; прод не рестартится в рамках merge. Тест AC-11. |
|
||||
|
||||
## Сводный вывод
|
||||
Изменения локализованы в `src/merge_gate.py` + врезка в `_handle_merge_verify`
|
||||
(`src/stage_engine.py`) + новый ключ конфигурации + корневой `.gitattributes`. Схема БД, Plane,
|
||||
внешние HTTP-эндпоинты, реестр QG, `STAGE_TRANSITIONS` — не затронуты. Главный остаточный риск —
|
||||
ложный HOLD на инфра-сбое (R-1), сознательно принят ради устранения ложно-зелёного merge-verify.
|
||||
75
docs/work-items/ORCH-073/12-review.md
Normal file
75
docs/work-items/ORCH-073/12-review.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-073
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-073
|
||||
|
||||
## Summary
|
||||
Системный фикс эрозии `main` (фантомный merge ORCH-067/069) реализован строго по
|
||||
ТЗ (FR-1…FR-5) и ADR-001. Все 11 критериев приёмки выполнены, документация обновлена
|
||||
в том же PR, `pytest tests/ -q` → **941 passed**. Self-hosting-инварианты соблюдены
|
||||
(merge только через Gitea PR-API, без force-push в `main`; non-self репо — no-op).
|
||||
Блокирующих и must-fix замечаний нет.
|
||||
|
||||
## Проверка по осям
|
||||
|
||||
### 1. Соответствие ТЗ (02-trz.md)
|
||||
- **FR-1** — `verify_merged_to_main` подтверждает merge ТОЛЬКО `git merge-base --is-ancestor <sha> origin/main`; OR-ветка `pr_already_merged` удалена; пустой SHA / git-ошибка → `False` (fail-closed, never-raise). ✓
|
||||
- **FR-2** — `pr_already_merged` понижен до idempotency-guard, явный in-loop фильтр `merged & head.ref==branch & base.ref=="main"` (не ненадёжный query `head`). ✓
|
||||
- **FR-3** — `merge_pr` выбирает open PR по `head.ref==branch` И `base.ref=="main"`; merge только `POST /pulls/{n}/merge`. ✓
|
||||
- **FR-4** — корневой `.gitattributes` с `CHANGELOG.md merge=union`; `docs/**` намеренно НЕ включён. ✓
|
||||
- **FR-5** — `check_main_regression` (детерминированный, no-LLM) + декларативный append-only `MAIN_REGRESSION_MARKERS`; вызов в `_handle_merge_verify` ПОСЛЕ SHA-в-main и ДО `done`; ALERT-only + HOLD; fail-open на git-ошибке грепа; kill-switch `regression_guard_enabled`. ✓
|
||||
|
||||
### 2. Соответствие ADR (06-adr/ADR-001 + adr-0014)
|
||||
Реализация 1:1 соответствует Р-1…Р-5. G4-аудит и root-cause зафиксированы в ADR
|
||||
(раздел «Root cause (G4 audit)»). Сквозной ADR-0014 заведён, `adr/README.md` обновлён,
|
||||
`adr-0013` помечен как amended. Нарушений глобальных ADR не обнаружено.
|
||||
**AC-1 подтверждён в `origin/main`:** `plane_issue_link`(8), `qg0_title_max`(config.py 3),
|
||||
`verify_merged_to_main`(4). **AC-4 подтверждён:** `git check-attr merge CHANGELOG.md → merge: union`.
|
||||
|
||||
### 3. Качество кода
|
||||
- Строгий never-raise на всех публичных функциях merge_gate; INV-1…INV-5 соблюдены.
|
||||
- Docstrings содержательные, со ссылками на FR/AC/INV; обоснован осознанный trade-off
|
||||
fail-open для marker-grep против fail-closed SHA-в-main.
|
||||
- `_hold_main_regressed` симметричен not-merged-HOLD; уведомления Plane/Telegram best-effort,
|
||||
не ломают HOLD.
|
||||
- Схема БД, реестр `QG_CHECKS`, `STAGE_TRANSITIONS`, внешние HTTP-эндпоинты — не тронуты (как и заявлено).
|
||||
|
||||
### 4. Качество тестов
|
||||
18 тест-кейсов (TC-01…18) в 6 файлах `tests/test_orch073_*.py`, не тривиальные:
|
||||
- TC-02 воспроизводит исходный баг (merged docs-PR не подтверждает merge), проверяет, что
|
||||
PR-флаг verify-ом более не запрашивается.
|
||||
- TC-14/15 различают HOLD по «not-merged» и по «main-regressed».
|
||||
- TC-10 — идемпотентность (нет второго POST merge). TC-17/18 — conditionality/kill-switch.
|
||||
- TC-12 в throwaway-репо реально проверяет union-merge без конфликта.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- Маркер `("ORCH-073", "check_main_regression", "src/merge_gate.py")` самозагрузочный
|
||||
(попадёт в `origin/main` только после merge этой задачи) — поведение корректное и
|
||||
оговорено в ADR (self-bootstrap), замечание чисто информационное.
|
||||
|
||||
## Документация
|
||||
Полностью обновлена в этом же PR (правило агентов §2/§6, AC-8):
|
||||
- `docs/architecture/README.md` — раздел merge-verify переписан под FR-1 + добавлены регресс-гард (FR-5) и `.gitattributes` (FR-4).
|
||||
- `CHANGELOG.md` — запись в `## [Unreleased]`.
|
||||
- `docs/work-items/ORCH-073/06-adr/ADR-001-*.md` — новый ADR с G4-аудитом; `docs/architecture/adr/adr-0014-*.md` — сквозной ADR; `adr/README.md` обновлён.
|
||||
- `.env.example` — задокументирован новый ключ `ORCH_REGRESSION_GUARD_ENABLED` + блок merge-verify.
|
||||
|
||||
Требование «изменён `src/` → обновлена документация» выполнено. Блокеров по документации нет.
|
||||
|
||||
## Вердикт
|
||||
**APPROVED** — нет P0/P1; код, тесты и документация соответствуют ТЗ/ADR; self-hosting-страховки сохранены.
|
||||
83
docs/work-items/ORCH-073/13-test-report.md
Normal file
83
docs/work-items/ORCH-073/13-test-report.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-073
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-073
|
||||
|
||||
CRIT: системный фикс эрозии `main` (фантомный merge ORCH-067/069) + восстановление кода.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Дата: 2026-06-08
|
||||
- Worktree: `feature/ORCH-073-crit-main-orch-067-069`
|
||||
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — контейнер не тронут
|
||||
|
||||
## Smoke-тесты API (prod 8500, read-only)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok"}` — PASS |
|
||||
| `GET /status` | active_tasks отдаётся, ORCH-073 на стадии `testing` — PASS |
|
||||
| `GET /queue` | counts/reconcile/reaper/post_deploy снимок отдаётся, breaker `closed` — PASS |
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Тест-функция | Результат |
|
||||
|-------|----------|--------------|-----------|
|
||||
| TC-01 | verify_merged_to_main: sha — предок main → True (AC-6) | test_tc01_true_when_sha_is_ancestor | PASS |
|
||||
| TC-02 | sha НЕ в main + merged docs-PR → False (баг 067/069, AC-2) | test_tc02_false_when_sha_not_in_main_even_with_merged_docs_pr | PASS |
|
||||
| TC-03 | пустой sha → False (fail-closed) | test_tc03_empty_sha_is_false | PASS |
|
||||
| TC-04 | git error → False (never-raise, INV-1) | test_tc04_never_raises_on_git_error / _worktree_error | PASS |
|
||||
| TC-05 | merged docs-PR не засчитан как code-merge (FR-2) | test_tc05_merged_docs_pr_not_counted | PASS |
|
||||
| TC-06 | merged code-PR распознан (base=main, head=branch) | test_tc06_merged_code_pr_recognised / _onto_non_main_base_not_counted | PASS |
|
||||
| TC-07 | HTTP-ошибка/не-200 → False (never-raise) | test_tc07_non_200_is_false / _http_exception_is_false | PASS |
|
||||
| TC-08 | merge_pr выбирает code-PR, не docs/* (FR-3) | test_tc08_merges_code_pr_not_docs_pr / _skips_pr_onto_non_main_base | PASS |
|
||||
| TC-09 | нет open code-PR → (False,...), без push main (INV-2) | test_tc09_no_open_pr_no_shell_out | PASS |
|
||||
| TC-10 | merge_pr идемпотентен, без второго POST (AC-7/INV-4) | test_tc10_idempotent_already_merged | PASS |
|
||||
| TC-11 | .gitattributes: CHANGELOG.md merge=union (AC-4) | test_tc11_gitattributes_declares_union | PASS |
|
||||
| TC-12 | union-merge сохраняет обе записи Unreleased без конфликта | test_tc12_union_merge_keeps_both_entries | PASS |
|
||||
| TC-13 | _handle_merge_verify: SHA в main + маркеры → advance (AC-6) | test_tc13_confirmed_and_intact_advances | PASS |
|
||||
| TC-14 | docs-only merge → HOLD + alert, НЕ done (AC-3) | test_tc14_sha_not_in_main_holds | PASS |
|
||||
| TC-15 | регресс-гард: маркер ранее-merged задачи пропал → HOLD + alert (AC-5) | test_tc15_marker_missing_holds | PASS |
|
||||
| TC-16 | внутр. ошибка верификатора → HOLD + alert, never-raise (INV-1) | test_tc16_internal_error_holds_never_raises | PASS |
|
||||
| TC-17 | conditionality: non-self/kill-switch → под-гейт no-op (AC-6/INV-5) | test_tc17_merge_verify_applies_scope / _under_gate_noop_for_non_self | PASS |
|
||||
| TC-18 | регресс-гард уважает kill-switch / non-self → no-op (INV-5) | test_tc18_guard_kill_switch_skips_guard / _guard_noop_for_non_self_repo | PASS |
|
||||
| TC-19 | полный pytest tests/ -q зелёный (AC-8) | весь набор tests/ | PASS |
|
||||
|
||||
Все 19 TC из тест-плана покрыты (24 тест-функции в 6 файлах `tests/test_orch073_*.py`).
|
||||
|
||||
## Проверка критериев приёмки (03-acceptance-criteria.md)
|
||||
|
||||
| AC | Проверка | Результат |
|
||||
|----|----------|-----------|
|
||||
| AC-1 | Маркеры в origin/main: plane_issue_link=8, qg0_title_max=3, verify_merged_to_main=4 (все >0) | PASS |
|
||||
| AC-2 | TC-02: sha не в main + merged docs-PR → False | PASS |
|
||||
| AC-3 | TC-14: docs-only merge → HOLD + alert, НЕ done | PASS |
|
||||
| AC-4 | `git check-attr merge CHANGELOG.md` → `merge: union`; TC-11/12 | PASS |
|
||||
| AC-5 | TC-15: уменьшение набора маркеров → HOLD + alert «main regressed» | PASS |
|
||||
| AC-6 | TC-01/13/17: happy-path done без ложного alert; enduro no-op | PASS |
|
||||
| AC-7 | TC-10: re-drive слитой задачи → no-op, без второго merge | PASS |
|
||||
| AC-8 | 941 passed; доки/ADR/CHANGELOG обновлены (см. 12-review) | PASS |
|
||||
| AC-9 | G4-аудит в ADR-001 (root cause docs-only merge) — подтверждён reviewer | PASS |
|
||||
| AC-10 | staging-проверка — стадия deploy-staging (вне scope tester) | — |
|
||||
| AC-11 | merge только PR-API; прод-контейнер не падал в рамках тестов | PASS |
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
```
|
||||
tests/ -q --tb=short:
|
||||
........................................................................ [100%]
|
||||
941 passed, 1 warning in 25.37s
|
||||
|
||||
tests/test_orch073_*.py -v:
|
||||
24 passed, 1 warning in 0.54s
|
||||
```
|
||||
(1 warning — PydanticDeprecatedSince20 в src/config.py, не относится к ORCH-073, не блокирует.)
|
||||
|
||||
## Итог
|
||||
**PASS** — полный регресс зелёный (941 passed), все 24 теста ORCH-073 PASS, smoke API OK,
|
||||
маркеры AC-1 присутствуют в `origin/main`, прод-контейнер не затронут. Задача готова к
|
||||
переходу на стадию `deploy-staging` (где будет проверен AC-10 — воспроизведение «исправлено
|
||||
навсегда» на двух задачах с правкой CHANGELOG).
|
||||
12
docs/work-items/ORCH-073/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-073/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-073
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
@@ -396,6 +396,19 @@ class Settings(BaseSettings):
|
||||
merge_pr_timeout_s: int = 60
|
||||
merge_verify_timeout_s: int = 60
|
||||
|
||||
# ORCH-073 (ADR-001 Р-4): main-integrity regression guard. After the merge-verify
|
||||
# under-gate confirms the deployed SHA is an ancestor of origin/main (FR-1), a
|
||||
# secondary deterministic (no-LLM) guard checks that a declarative set of markers
|
||||
# for recently-merged tasks (MAIN_REGRESSION_MARKERS in merge_gate.py) is still
|
||||
# present in origin/main — i.e. a CHANGELOG-rebase or phantom-merge did not silently
|
||||
# roll back a neighbouring task's code. A missing marker (deterministic count==0) ->
|
||||
# ALERT + HOLD (task stays on `deploy`, NOT done); an infra/git error on the grep
|
||||
# itself -> fail-OPEN (do not block done; SHA-in-main remains the primary gate).
|
||||
# regression_guard_enabled -> kill-switch (env ORCH_REGRESSION_GUARD_ENABLED);
|
||||
# reuses the merge_verify_applies scope (self-hosting /
|
||||
# merge_verify_repos), so non-self repos are a no-op.
|
||||
regression_guard_enabled: bool = True
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
@@ -445,25 +445,30 @@ def reclaim_stale_lease(repo: str) -> bool:
|
||||
# ORCH-065: idempotent merge finalization guard (Problem C)
|
||||
# ---------------------------------------------------------------------------
|
||||
def pr_already_merged(repo: str, branch: str) -> bool:
|
||||
"""Return True iff the PR for ``branch`` is ALREADY merged (ADR-001 Р-3, FR-3.2).
|
||||
"""Return True iff the **code-PR of ``branch``** is ALREADY merged (idempotency-guard).
|
||||
|
||||
A deterministic, read-only guard the merge path consults BEFORE attempting a
|
||||
(second) merge so a re-driven / reaped task is idempotent: an already-merged
|
||||
PR -> no-op, never a duplicate merge and never an error. This is the ONLY new
|
||||
merge-related helper and it does NOT merge — it only READS the PR state via
|
||||
the existing Gitea client, so it does not introduce duplicate merge logic.
|
||||
ORCH-073 ADR-001 Р-2 (FR-2): this is an **idempotency-guard for ``merge_pr``**, NOT
|
||||
a source of truth for ``done`` (the only proof of merge is SHA-in-main, FR-1 /
|
||||
``verify_merged_to_main``). It lets a re-driven / reaped ``merge_pr`` be idempotent:
|
||||
the code-PR is already merged -> no-op, never a duplicate merge.
|
||||
|
||||
Consultation point: the actual merge actor is the **deployer agent** (it merges
|
||||
the feature PR at the start of the ``deploy`` stage — see webhooks/gitea.py),
|
||||
so the wiring lives in the deployer prompt (``.openclaw/agents/deployer.md``),
|
||||
which runs this exact function before any (re-)merge. The merge-gate quality
|
||||
check (``qg.checks.check_branch_mergeable``) is intentionally NOT modified
|
||||
(ORCH-065 AC-13: ``check_*`` behaviour unchanged) — it runs on the FIRST
|
||||
deploy-staging -> deploy edge and does not re-run on a ``deploy``-stage re-drive,
|
||||
which is exactly where the second-merge risk lives.
|
||||
Root-cause fix (G4 audit): the previous implementation returned True for ANY
|
||||
``merged == True`` PR returned by ``GET /pulls?state=all&head=<branch>``. Gitea's
|
||||
``head`` query-param filters unreliably for a bare branch name, so auto docs-PRs
|
||||
(staging/deploy logs, ``head=docs/*``) leaked into the result and were counted as
|
||||
"merged" — the ORCH-067/069 phantom-merge. We now apply an EXPLICIT in-loop filter
|
||||
instead of trusting the query-param: a PR counts only when it carries the code of
|
||||
THIS feature-branch into ``main``:
|
||||
|
||||
* ``pr.merged is True`` AND
|
||||
* ``pr.head.ref == branch`` (the code of exactly this feature-branch) AND
|
||||
* ``pr.base.ref == "main"`` (target is main, not a docs/other base).
|
||||
|
||||
This excludes auto docs-PRs (different ``head.ref``) and PRs onto a non-``main``
|
||||
base, so a merged docs-PR can no longer make ``merge_pr`` skip a real code merge.
|
||||
|
||||
Queries Gitea ``GET /repos/{owner}/{repo}/pulls?state=all&head=<branch>`` and
|
||||
reports True when any matching PR has ``merged == True``. Never raises (AC-9):
|
||||
reports True only when a matching PR passes the filter above. Never raises (AC-9):
|
||||
any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the
|
||||
normal gate re-evaluate rather than silently skipping a real merge).
|
||||
"""
|
||||
@@ -479,7 +484,11 @@ def pr_already_merged(repo: str, branch: str) -> bool:
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
for pr in resp.json() or []:
|
||||
if pr.get("merged") is True:
|
||||
if (
|
||||
pr.get("merged") is True
|
||||
and pr.get("head", {}).get("ref") == branch
|
||||
and pr.get("base", {}).get("ref") == "main"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
@@ -505,6 +514,7 @@ def pr_already_merged(repo: str, branch: str) -> bool:
|
||||
_MERGE_VERIFY_COUNTERS: dict = {
|
||||
"merge_verified_total": 0,
|
||||
"not_merged_alerts_total": 0,
|
||||
"main_regressed_alerts_total": 0, # ORCH-073 Р-4: regression-guard HOLD+alert count.
|
||||
"last_alert_wi": None,
|
||||
}
|
||||
|
||||
@@ -526,6 +536,15 @@ def note_not_merged_alert(work_item_id: str | None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def note_main_regressed_alert(work_item_id: str | None) -> None:
|
||||
"""Bump the 'main regressed (marker missing)' counter (ORCH-073 Р-4). Never raises."""
|
||||
try:
|
||||
_MERGE_VERIFY_COUNTERS["main_regressed_alerts_total"] += 1
|
||||
_MERGE_VERIFY_COUNTERS["last_alert_wi"] = work_item_id
|
||||
except Exception: # noqa: BLE001 - observability must never break a decision
|
||||
pass
|
||||
|
||||
|
||||
def merge_verify_status() -> dict:
|
||||
"""Snapshot of the merge-verify under-gate for GET /queue. Never raises."""
|
||||
try:
|
||||
@@ -534,6 +553,7 @@ def merge_verify_status() -> dict:
|
||||
"repos": settings.merge_verify_repos or "",
|
||||
"merge_verified_total": _MERGE_VERIFY_COUNTERS["merge_verified_total"],
|
||||
"not_merged_alerts_total": _MERGE_VERIFY_COUNTERS["not_merged_alerts_total"],
|
||||
"main_regressed_alerts_total": _MERGE_VERIFY_COUNTERS["main_regressed_alerts_total"],
|
||||
"last_alert_wi": _MERGE_VERIFY_COUNTERS["last_alert_wi"],
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
@@ -578,7 +598,10 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
Algorithm:
|
||||
1. ``pr_already_merged`` -> True -> no-op ``(True, "already-merged")`` (INV-5/AC-9).
|
||||
2. ``GET /repos/{owner}/{repo}/pulls?state=open`` -> the open PR whose head ref
|
||||
== ``branch`` -> its index. No open PR -> ``(False, "no open PR")``.
|
||||
== ``branch`` AND base ref == ``main`` -> its index. ORCH-073 ADR-001 Р-3
|
||||
(FR-3) adds the ``base == main`` filter so the actor merges exactly the
|
||||
feature code-PR and never an auto docs-PR / a PR onto a foreign base. No
|
||||
such open PR -> ``(False, "no open PR")``.
|
||||
3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) ->
|
||||
200/201 -> ``(True, "merged PR #<n>")``; otherwise ``(False, "<reason>")``.
|
||||
|
||||
@@ -602,7 +625,10 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
return False, f"list PRs failed: HTTP {resp.status_code}"
|
||||
index = None
|
||||
for pr in resp.json() or []:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
if (
|
||||
pr.get("head", {}).get("ref") == branch
|
||||
and pr.get("base", {}).get("ref") == "main"
|
||||
):
|
||||
index = pr.get("number")
|
||||
break
|
||||
if index is None:
|
||||
@@ -631,26 +657,32 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
def verify_merged_to_main(repo: str, branch: str, sha: str) -> bool:
|
||||
"""Return True iff the deployed commit is confirmed merged into ``origin/main``.
|
||||
|
||||
Post-deploy verification (FR-2 / D4): the merge is confirmed when EITHER
|
||||
* ``pr_already_merged(repo, branch)`` is True (Gitea ``PR.merged == true``), OR
|
||||
* ``git merge-base --is-ancestor <sha> origin/main`` succeeds in the per-branch
|
||||
worktree (after ``git fetch origin main``), i.e. the validated SHA is an
|
||||
ancestor of the current ``origin/main``.
|
||||
Post-deploy verification — ORCH-073 ADR-001 Р-1 (FR-1): the merge is confirmed by
|
||||
the SINGLE, authoritative fact "the deployed commit IS an ancestor of the current
|
||||
``origin/main``":
|
||||
|
||||
* after ``git fetch origin main`` (in the per-branch worktree),
|
||||
``git merge-base --is-ancestor <sha> origin/main`` returns ``rc == 0``.
|
||||
|
||||
The former OR-branch ``pr_already_merged(repo, branch)`` was REMOVED: a merged
|
||||
``PR.merged == true`` is no longer sufficient to confirm a merge. That branch was
|
||||
the ORCH-067/069 phantom-merge root cause — an auto docs-PR (staging/deploy logs)
|
||||
counted as "merged" via the unreliable Gitea ``head`` query, turning merge-verify
|
||||
falsely GREEN while the code-PR was never merged. ``pr_already_merged`` now serves
|
||||
ONLY as an idempotency-guard inside ``merge_pr`` (Р-2/Р-3), never as proof of merge.
|
||||
|
||||
``sha`` is the validated commit (``image_freshness.validated_revision`` =
|
||||
worktree ``git rev-parse HEAD``). An empty ``sha`` makes the git branch
|
||||
inconclusive (only the PR-merged branch can then confirm).
|
||||
worktree ``git rev-parse HEAD``). An empty ``sha`` is inconclusive -> ``False``
|
||||
(fail-closed: alert + HOLD), since the SHA-in-main check cannot run without it.
|
||||
|
||||
Never-raise (INV-1/AC-7 / TC-04): any git/HTTP error -> ``False`` (= "not
|
||||
confirmed" -> fail-closed for ``done``: alert + HOLD). The exception is NEVER
|
||||
propagated into ``advance_stage``.
|
||||
"""
|
||||
try:
|
||||
if pr_already_merged(repo, branch):
|
||||
return True
|
||||
if not sha:
|
||||
logger.warning(
|
||||
"verify_merged_to_main: empty SHA for %s/%s and PR not known-merged",
|
||||
"verify_merged_to_main: empty SHA for %s/%s -> cannot confirm SHA-in-main",
|
||||
repo, branch,
|
||||
)
|
||||
return False
|
||||
@@ -675,3 +707,110 @@ def verify_merged_to_main(repo: str, branch: str, sha: str) -> bool:
|
||||
"verify_merged_to_main unexpected error for %s/%s: %s", repo, branch, e
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-073 (ADR-001 Р-4): main-integrity regression guard.
|
||||
#
|
||||
# A secondary, deterministic (no-LLM) guard that runs in `_handle_merge_verify`
|
||||
# AFTER the SHA-in-main check (verify_merged_to_main, FR-1) confirms the deployed
|
||||
# commit, and BEFORE the task is stamped `done`. It checks that a DECLARATIVE set
|
||||
# of markers for recently-merged tasks is still present in `origin/main` — i.e. a
|
||||
# CHANGELOG-rebase / phantom-merge did not silently roll back a neighbouring task's
|
||||
# code (the ORCH-067/069 failure mode, which SHA-in-main alone would not catch when
|
||||
# the deployed SHA itself IS in main but a sibling's code is gone).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Declarative, append-only marker set (ADR-001 Р-4). Each future task that lands
|
||||
# significant code SHOULD append its own (task, marker_substring, path) row so the
|
||||
# guard protects it from a later phantom-merge / rebase rollback. Kept in code (not
|
||||
# DB / Plane — a non-goal) so it versions together with the fix it protects.
|
||||
MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [
|
||||
("ORCH-067", "plane_issue_link", "src/notifications.py"),
|
||||
("ORCH-069", "qg0_title_max", "src/config.py"),
|
||||
("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
|
||||
("ORCH-073", "check_main_regression", "src/merge_gate.py"),
|
||||
]
|
||||
|
||||
|
||||
def check_main_regression(repo: str, branch: str) -> tuple[bool, str]:
|
||||
"""Verify the declarative marker set is still present in ``origin/main``.
|
||||
|
||||
ORCH-073 ADR-001 Р-4 (FR-5). For each ``(task, marker, path)`` in
|
||||
``MAIN_REGRESSION_MARKERS`` run ``git grep -c <marker> origin/main -- <path>`` in
|
||||
the per-branch worktree (after ``git fetch origin main``). A DETERMINISTIC count
|
||||
of ``0`` for any marker means a neighbouring task's code was rolled back ->
|
||||
regression.
|
||||
|
||||
Returns ``(ok, reason)``:
|
||||
* ``(True, "markers intact (<n>)")`` — every marker present -> proceed.
|
||||
* ``(False, "main regressed: <task> ...")`` — a marker is deterministically
|
||||
absent (count==0) -> caller HOLDs the task (NOT done) + alerts.
|
||||
|
||||
**Fail-OPEN on infra error** (intentional trade-off, ADR-001 Р-4): any git/OS
|
||||
error on the grep itself -> ``(True, "guard inconclusive: <reason>")`` so a flaky
|
||||
git never produces a false HOLD. "Regressed" is asserted ONLY on a deterministic
|
||||
``count == 0``, never on "could not determine". The PRIMARY fail-closed gate is
|
||||
SHA-in-main (FR-1); this marker-grep is a secondary, best-effort guard.
|
||||
|
||||
Never raises (INV-1): any unexpected error -> ``(True, "guard error: ...")``.
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
wt = ensure_worktree(repo, branch)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract -> fail-open
|
||||
logger.warning(
|
||||
"check_main_regression: worktree error for %s/%s: %s (fail-open)",
|
||||
repo, branch, e,
|
||||
)
|
||||
return True, f"guard inconclusive: worktree error: {e}"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", wt, "fetch", "origin", "main"],
|
||||
capture_output=True, timeout=settings.merge_verify_timeout_s,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(
|
||||
"check_main_regression: fetch error for %s/%s: %s (fail-open)",
|
||||
repo, branch, e,
|
||||
)
|
||||
return True, f"guard inconclusive: fetch error: {e}"
|
||||
|
||||
for task, marker, path in MAIN_REGRESSION_MARKERS:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "-C", wt, "grep", "-c", marker, "origin/main", "--", path],
|
||||
capture_output=True, text=True, timeout=_SHORT_TIMEOUT,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
# Infra error on this marker -> fail-open (do NOT assert regression).
|
||||
logger.warning(
|
||||
"check_main_regression: grep error for %s (%s @ %s): %s (fail-open)",
|
||||
task, marker, path, e,
|
||||
)
|
||||
return True, f"guard inconclusive: grep error for {task}: {e}"
|
||||
# git grep exit codes: 0 = match(es) found, 1 = no match, >1 = real error.
|
||||
if r.returncode == 0:
|
||||
continue
|
||||
if r.returncode == 1:
|
||||
# Deterministic absence -> regression of a neighbouring task's code.
|
||||
logger.warning(
|
||||
"check_main_regression: marker MISSING in origin/main for %s "
|
||||
"(%s @ %s) -> main regressed", task, marker, path,
|
||||
)
|
||||
return False, f"main regressed: {task} code missing ({marker} @ {path})"
|
||||
# rc > 1 -> git error (e.g. bad path/ref) -> inconclusive -> fail-open.
|
||||
logger.warning(
|
||||
"check_main_regression: ambiguous git grep rc=%s for %s (%s @ %s) "
|
||||
"(fail-open)", r.returncode, task, marker, path,
|
||||
)
|
||||
return True, f"guard inconclusive: git grep rc={r.returncode} for {task}"
|
||||
|
||||
return True, f"markers intact ({len(MAIN_REGRESSION_MARKERS)})"
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract -> fail-open
|
||||
logger.warning(
|
||||
"check_main_regression unexpected error for %s/%s: %s (fail-open)",
|
||||
repo, branch, e,
|
||||
)
|
||||
return True, f"guard error: {e}"
|
||||
|
||||
@@ -1277,6 +1277,50 @@ def _deploy_finalize_defer_count(task_id: int) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def _hold_main_regressed(
|
||||
task_id, repo, work_item_id, branch, guard_msg: str, result: AdvanceResult
|
||||
) -> bool:
|
||||
"""HOLD the task because the regression guard found neighbouring code missing.
|
||||
|
||||
ORCH-073 Р-4 (FR-5 / AC-5): the deployed SHA IS in `main` (FR-1 passed) but a
|
||||
declarative marker of a recently-merged task is gone -> a phantom-merge / rebase
|
||||
rolled back sibling code. Reaction is ALERT-only + HOLD (Telegram + Plane
|
||||
``set_issue_blocked`` + comment), task stays on `deploy` (NOT done), NO rollback
|
||||
to development (an infra defect, not a code fault — symmetric to the not-merged
|
||||
HOLD). Returns ``True`` (INTERVENED). Never breaks the HOLD on a notify error.
|
||||
"""
|
||||
merge_gate.note_main_regressed_alert(work_item_id)
|
||||
msg = (
|
||||
f"main regressed: {guard_msg} (repo={repo}, branch={branch}, "
|
||||
f"wi={work_item_id}). Соседний код пропал из `main` — задача удержана на "
|
||||
f"`deploy` (НЕ done). Нужно ручное восстановление кода."
|
||||
)
|
||||
logger.warning(f"Task {task_id}: {msg}")
|
||||
if work_item_id:
|
||||
try:
|
||||
set_issue_blocked(work_item_id)
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: set_issue_blocked failed: {e}")
|
||||
try:
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
"\U0001f6a8 Регресс `main`: " + guard_msg + ". Код соседней задачи "
|
||||
"пропал из `main`. Задача удержана на `deploy` (НЕ done) — нужно "
|
||||
"восстановить код и повторить approve.",
|
||||
author="deployer",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: plane regressed comment failed: {e}")
|
||||
try:
|
||||
send_telegram(f"\U0001f6a8 {msg}")
|
||||
except Exception as e: # noqa: BLE001 - never break the HOLD
|
||||
logger.warning(f"Task {task_id}: main-regressed telegram failed: {e}")
|
||||
result.alerted = True
|
||||
result.note = "main-regressed-hold"
|
||||
result.advanced = False
|
||||
return True
|
||||
|
||||
|
||||
def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceResult) -> bool:
|
||||
"""ORCH-071 merge-verify under-gate on the `deploy -> done` edge.
|
||||
|
||||
@@ -1317,6 +1361,20 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
|
||||
|
||||
confirmed = merge_gate.verify_merged_to_main(repo, branch, sha)
|
||||
if confirmed:
|
||||
# ORCH-073 Р-4 (FR-5): secondary main-integrity regression guard. The
|
||||
# deployed SHA is in main (FR-1), but a CHANGELOG-rebase / phantom-merge
|
||||
# could still have rolled back a NEIGHBOURING task's code. Verify the
|
||||
# declarative marker set is intact; a deterministic miss -> HOLD + alert
|
||||
# (NOT done, no rollback — an infra defect, not a code fault). Fail-OPEN
|
||||
# on a git error of the guard itself (SHA-in-main remains the primary
|
||||
# gate). Honours the same scope/kill-switch as the under-gate.
|
||||
if settings.regression_guard_enabled:
|
||||
guard_ok, guard_msg = merge_gate.check_main_regression(repo, branch)
|
||||
if not guard_ok:
|
||||
return _hold_main_regressed(
|
||||
task_id, repo, work_item_id, branch, guard_msg, result
|
||||
)
|
||||
|
||||
merge_gate.note_merge_verified()
|
||||
try:
|
||||
self_deploy.record_merged_to_main(repo, work_item_id, branch, True)
|
||||
|
||||
@@ -94,4 +94,8 @@ def _disable_merge_verify(monkeypatch):
|
||||
"""
|
||||
from src import config as _cfg
|
||||
monkeypatch.setattr(_cfg.settings, "merge_verify_enabled", False, raising=False)
|
||||
# ORCH-073: the regression guard (check_main_regression) runs real git in
|
||||
# _handle_merge_verify's confirmed branch. Default it OFF too so unrelated
|
||||
# deploy->done tests stay 1:1; the dedicated ORCH-073 tests re-enable it.
|
||||
monkeypatch.setattr(_cfg.settings, "regression_guard_enabled", False, raising=False)
|
||||
yield
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_tc07_merge_actor_calls_gitea_merge(monkeypatch):
|
||||
|
||||
def fake_get(url, params=None, headers=None, timeout=None):
|
||||
get_calls.append((url, params))
|
||||
return _Resp(200, [{"head": {"ref": branch}, "number": 7}])
|
||||
return _Resp(200, [{"head": {"ref": branch}, "base": {"ref": "main"}, "number": 7}])
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
post_calls.append((url, json))
|
||||
@@ -104,7 +104,7 @@ def test_tc09_never_raise_on_http_error(monkeypatch):
|
||||
def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 3}])
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "base": {"ref": "main"}, "number": 3}])
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(409, text="conflict"))
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
|
||||
@@ -119,7 +119,7 @@ def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
|
||||
def test_tc13_no_shell_out_no_force_push(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 9}])
|
||||
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "base": {"ref": "main"}, "number": 9}])
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(200))
|
||||
|
||||
|
||||
@@ -315,10 +315,17 @@ class _FakeResp:
|
||||
|
||||
|
||||
def test_tc16_pr_already_merged_true(monkeypatch):
|
||||
"""A merged PR -> True so a re-driven/reaped task is a no-op (no second merge)."""
|
||||
"""A merged code-PR -> True so a re-driven/reaped task is a no-op (no second merge).
|
||||
|
||||
ORCH-073 FR-2: the guard now counts a PR only when it carries THIS branch's code
|
||||
into main (merged & head.ref==branch & base.ref=="main").
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": True}]),
|
||||
lambda *a, **k: _FakeResp(
|
||||
200,
|
||||
[{"number": 7, "merged": True, "head": {"ref": "feature/x"}, "base": {"ref": "main"}}],
|
||||
),
|
||||
)
|
||||
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is True
|
||||
|
||||
|
||||
@@ -204,7 +204,9 @@ def test_tc17_pr_already_merged_makes_redrive_a_noop(race_repo, monkeypatch):
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return [{"merged": True}]
|
||||
# ORCH-073 FR-2: the guard counts a PR only when it carries THIS branch's
|
||||
# code into main (merged & head.ref==branch & base.ref=="main").
|
||||
return [{"merged": True, "head": {"ref": "feature/B"}, "base": {"ref": "main"}}]
|
||||
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _R())
|
||||
assert merge_gate.pr_already_merged(repo, "feature/B") is True
|
||||
|
||||
@@ -49,17 +49,22 @@ def test_tc01_verify_true_when_sha_is_ancestor(monkeypatch):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02: PR.merged==true short-circuits to True even if git is unavailable.
|
||||
# TC-02 (ORCH-073 FR-1): PR.merged==true NO LONGER confirms a merge. The former
|
||||
# OR-branch was the phantom-merge root cause (a merged docs-PR turned verify green).
|
||||
# SHA-in-main is now the SINGLE criterion; an empty SHA -> inconclusive -> False.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_verify_true_when_pr_merged_even_without_git(monkeypatch):
|
||||
def test_tc02_pr_merged_does_not_confirm_without_sha_in_main(monkeypatch):
|
||||
# Even if a (docs-)PR is reported merged, that must NOT short-circuit to True.
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("git must NOT be consulted when PR is already merged")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is True
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
# SHA not an ancestor of origin/main (rc=1) -> not confirmed despite merged PR.
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: _R(1) if "merge-base" in cmd else _R(0),
|
||||
)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
# And an empty SHA is inconclusive -> False (cannot prove SHA-in-main).
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -93,11 +98,13 @@ def test_tc04_verify_never_raises_on_git_error(monkeypatch):
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
def test_tc04_verify_never_raises_on_http_error(monkeypatch):
|
||||
def boom(r, b):
|
||||
raise RuntimeError("gitea down")
|
||||
def test_tc04_verify_never_raises_on_worktree_error(monkeypatch):
|
||||
# ORCH-073: verify no longer consults pr_already_merged; a worktree/git error
|
||||
# on the SHA-in-main path is the failure to swallow -> conservative False.
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("worktree exploded")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", boom)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
|
||||
|
||||
|
||||
|
||||
93
tests/test_orch073_conditionality.py
Normal file
93
tests/test_orch073_conditionality.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""ORCH-073 — conditionality / backward-compat (INV-5).
|
||||
|
||||
Covers TC-17/18 / AC-6. The whole under-gate and the regression guard are no-ops for
|
||||
non-self repos and when their kill-switches are off, so enduro-trails and a disabled
|
||||
self-host behave exactly as before.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch073_cond.db"))
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
from src import merge_gate, stage_engine, image_freshness # noqa: E402
|
||||
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
||||
|
||||
REPO = "orchestrator"
|
||||
WI = "ORCH-073"
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-17 (AC-6/INV-5): non-self repo / kill-switch off -> under-gate is a no-op.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_merge_verify_applies_scope(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "")
|
||||
# Empty CSV -> only the self-hosting repo.
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is True
|
||||
assert merge_gate.merge_verify_applies("enduro-trails") is False
|
||||
# Kill-switch off -> no-op for everyone.
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False)
|
||||
assert merge_gate.merge_verify_applies("orchestrator") is False
|
||||
|
||||
|
||||
def test_tc17_under_gate_noop_for_non_self(monkeypatch):
|
||||
# When the under-gate does not apply, _handle_merge_verify advances (False) and
|
||||
# never touches the merge-actor / verifier / guard.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
||||
|
||||
def must_not_call(*a, **k):
|
||||
raise AssertionError("under-gate must be a no-op for non-self repos")
|
||||
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", must_not_call)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", must_not_call)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "check_main_regression", must_not_call)
|
||||
|
||||
res = AdvanceResult()
|
||||
assert _handle_merge_verify(1, "enduro-trails", WI, BRANCH, res) is False
|
||||
assert res.alerted is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-18 (INV-5): regression guard respects its kill-switch -> no-op; SHA-in-main
|
||||
# alone still advances the task.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc18_guard_kill_switch_skips_guard(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
||||
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
|
||||
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #1"))
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run when disabled")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
res = AdvanceResult()
|
||||
# Guard disabled -> confirmed SHA-in-main advances straight to done (return False).
|
||||
assert _handle_merge_verify(1, REPO, WI, BRANCH, res) is False
|
||||
assert res.alerted is False
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
def test_tc18_guard_noop_for_non_self_repo(monkeypatch):
|
||||
# check_main_regression is only invoked inside the confirmed branch which itself
|
||||
# only runs when merge_verify_applies is True (self-hosting / CSV). For a non-self
|
||||
# repo the guard is never reached.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run for non-self")),
|
||||
)
|
||||
res = AdvanceResult()
|
||||
assert _handle_merge_verify(1, "enduro-trails", WI, BRANCH, res) is False
|
||||
85
tests/test_orch073_gitattributes.py
Normal file
85
tests/test_orch073_gitattributes.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""ORCH-073 FR-4 — .gitattributes: CHANGELOG.md merge=union.
|
||||
|
||||
Covers TC-11/TC-12 / AC-4. TC-11 asserts the repo-root .gitattributes declares the
|
||||
union driver (git check-attr). TC-12 proves, in a throwaway git repo, that two
|
||||
branches both editing '## [Unreleased]' merge WITHOUT a conflict and BOTH entries
|
||||
survive — exactly what stops auto_rebase_onto_main from rolling a branch back.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _git(cwd, *args, env=None):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=str(cwd), capture_output=True, text=True, env=env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 (AC-4): the repo-root .gitattributes declares CHANGELOG.md merge=union.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_gitattributes_declares_union():
|
||||
ga = REPO_ROOT / ".gitattributes"
|
||||
assert ga.is_file(), ".gitattributes must exist at the repo root"
|
||||
assert "CHANGELOG.md merge=union" in ga.read_text(encoding="utf-8")
|
||||
|
||||
r = _git(REPO_ROOT, "check-attr", "merge", "CHANGELOG.md")
|
||||
assert r.returncode == 0, r.stderr
|
||||
# Output form: 'CHANGELOG.md: merge: union'
|
||||
assert "merge: union" in r.stdout, r.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12 (AC-4): two Unreleased edits merge with no conflict; both kept.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _init_repo(tmp_path):
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null",
|
||||
"PATH": __import__("os").environ.get("PATH", ""),
|
||||
"HOME": str(tmp_path),
|
||||
}
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
assert _git(repo, "init", "-b", "main", env=env).returncode == 0
|
||||
(repo / ".gitattributes").write_text("CHANGELOG.md merge=union\n", encoding="utf-8")
|
||||
base = (
|
||||
"# Changelog\n\n## [Unreleased]\n\n### Common\n\n## [0.1.0]\n- initial\n"
|
||||
)
|
||||
(repo / "CHANGELOG.md").write_text(base, encoding="utf-8")
|
||||
_git(repo, "add", ".", env=env)
|
||||
assert _git(repo, "commit", "-m", "base", env=env).returncode == 0
|
||||
return repo, env
|
||||
|
||||
|
||||
def test_tc12_union_merge_keeps_both_entries(tmp_path):
|
||||
repo, env = _init_repo(tmp_path)
|
||||
|
||||
# Branch A adds its Unreleased line.
|
||||
_git(repo, "checkout", "-b", "task-a", env=env)
|
||||
txt = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
(repo / "CHANGELOG.md").write_text(
|
||||
txt.replace("### Common\n", "### Common\n- ORCH-A: feature A\n"), encoding="utf-8"
|
||||
)
|
||||
_git(repo, "commit", "-am", "task A changelog", env=env)
|
||||
|
||||
# Branch B (from main) adds a DIFFERENT Unreleased line at the same spot.
|
||||
_git(repo, "checkout", "main", env=env)
|
||||
_git(repo, "checkout", "-b", "task-b", env=env)
|
||||
txt = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
(repo / "CHANGELOG.md").write_text(
|
||||
txt.replace("### Common\n", "### Common\n- ORCH-B: feature B\n"), encoding="utf-8"
|
||||
)
|
||||
_git(repo, "commit", "-am", "task B changelog", env=env)
|
||||
|
||||
# Merge A into B — union must avoid a conflict and keep BOTH lines.
|
||||
m = _git(repo, "merge", "--no-edit", "task-a", env=env)
|
||||
result = (repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert m.returncode == 0, f"union merge must not conflict: {m.stdout}\n{m.stderr}"
|
||||
assert "<<<<<<<" not in result and ">>>>>>>" not in result
|
||||
assert "ORCH-A: feature A" in result
|
||||
assert "ORCH-B: feature B" in result
|
||||
106
tests/test_orch073_merge_pr.py
Normal file
106
tests/test_orch073_merge_pr.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""ORCH-073 FR-3 — merge_pr merges exactly the feature code-PR (base==main).
|
||||
|
||||
Covers TC-08..10 / AC-7 / INV-2/INV-4. The actor selects the open PR with
|
||||
head==branch AND base==main (never an auto docs-PR / foreign base), merges via the
|
||||
Gitea PR-merge API only (no push/force-push), and is idempotent on an already-merged
|
||||
code-PR. Gitea HTTP is mocked; never-raise -> (False, reason).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else []
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08: open code-PR (head==branch, base==main) -> POST /pulls/{n}/merge.
|
||||
# A concurrently-open docs-PR (head=docs/*) must be skipped.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_merges_code_pr_not_docs_pr(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
post_calls = []
|
||||
|
||||
def fake_get(url, params=None, headers=None, timeout=None):
|
||||
return _Resp(200, [
|
||||
{"head": {"ref": "docs/ORCH-073-log"}, "base": {"ref": "main"}, "number": 4},
|
||||
{"head": {"ref": BRANCH}, "base": {"ref": "main"}, "number": 7},
|
||||
])
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
post_calls.append((url, json))
|
||||
return _Resp(200)
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is True and "PR #7" in msg
|
||||
assert len(post_calls) == 1
|
||||
url, body = post_calls[0]
|
||||
assert url.endswith("/repos/admin/orchestrator/pulls/7/merge")
|
||||
assert body == {"Do": "merge"}
|
||||
|
||||
|
||||
def test_tc08_skips_pr_onto_non_main_base(monkeypatch):
|
||||
# Right head but base != main -> not a merge-to-main code-PR -> no open PR.
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
lambda *a, **k: _Resp(200, [{"head": {"ref": BRANCH}, "base": {"ref": "develop"}, "number": 9}]),
|
||||
)
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("must not POST merge for a non-main base PR")))
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is False and msg == "no open PR"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-09 (INV-2): no open code-PR -> (False, "no open PR"); never shells out.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc09_no_open_pr_no_shell_out(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, []))
|
||||
subprocess_calls = []
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: subprocess_calls.append(cmd),
|
||||
)
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is False and msg == "no open PR"
|
||||
# No git push/force-push (or any subprocess) for the merge-actor.
|
||||
assert subprocess_calls == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10 (AC-7/INV-4): already-merged code-PR -> no-op, no second POST merge.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc10_idempotent_already_merged(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
|
||||
|
||||
def must_not_call(*a, **k):
|
||||
raise AssertionError("no Gitea call when the code-PR is already merged")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", must_not_call)
|
||||
monkeypatch.setattr(httpx, "post", must_not_call)
|
||||
ok, msg = merge_gate.merge_pr("orchestrator", BRANCH)
|
||||
assert ok is True and msg == "already-merged"
|
||||
99
tests/test_orch073_merge_verify.py
Normal file
99
tests/test_orch073_merge_verify.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""ORCH-073 FR-1 — verify_merged_to_main: SHA-in-main is the SINGLE criterion.
|
||||
|
||||
Covers TC-01..04 / AC-2 / AC-6. The former OR-branch `pr_already_merged` was the
|
||||
phantom-merge root cause and is removed: a merged docs-PR must NOT confirm a merge.
|
||||
git/HTTP are mocked; the verifier honours the never-raise contract (INV-1).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
|
||||
class _R:
|
||||
"""Minimal completed-subprocess stand-in (returncode only)."""
|
||||
|
||||
def __init__(self, rc):
|
||||
self.returncode = rc
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_verify_timeout_s", 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 (AC-6): sha is an ancestor of origin/main (merge-base rc=0) -> True.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_true_when_sha_is_ancestor(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, *a, **k):
|
||||
calls.append(cmd)
|
||||
return _R(0) # fetch ok; merge-base --is-ancestor -> 0 (ancestor)
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", fake_run)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is True
|
||||
assert any(
|
||||
"merge-base" in c and "--is-ancestor" in c and "origin/main" in c for c in calls
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 (AC-2): sha NOT in main AND a merged docs-PR exists -> False.
|
||||
# This is the exact ORCH-067/069 bug: a merged docs-PR must not confirm.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_false_when_sha_not_in_main_even_with_merged_docs_pr(monkeypatch):
|
||||
# A merged docs-PR is present (mock returns True), but it must be IGNORED.
|
||||
called = {"pr": False}
|
||||
|
||||
def fake_pr_already_merged(r, b):
|
||||
called["pr"] = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(merge_gate, "pr_already_merged", fake_pr_already_merged)
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
monkeypatch.setattr(
|
||||
merge_gate.subprocess, "run",
|
||||
lambda cmd, *a, **k: _R(1) if "merge-base" in cmd else _R(0),
|
||||
)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
# The merged-PR signal is no longer consulted by the verifier at all.
|
||||
assert called["pr"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03: empty sha -> inconclusive -> False (fail-closed), no git consulted.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_empty_sha_is_false(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("git must NOT run for an empty SHA")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 (INV-1): a git/OS error -> False, exception never propagated.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_never_raises_on_git_error(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
|
||||
|
||||
def boom(*a, **k):
|
||||
raise OSError("git exploded")
|
||||
|
||||
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
|
||||
|
||||
def test_tc04_never_raises_on_worktree_error(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("worktree down")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "ensure_worktree", boom)
|
||||
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-073-x", "abc123") is False
|
||||
78
tests/test_orch073_pr_classify.py
Normal file
78
tests/test_orch073_pr_classify.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""ORCH-073 FR-2 — pr_already_merged distinguishes code-PR from docs-PR.
|
||||
|
||||
Covers TC-05..07. pr_already_merged is now an idempotency-guard: it counts a PR as
|
||||
"merged" ONLY when it carries the code of THIS feature-branch into main
|
||||
(merged & head.ref==branch & base.ref=="main"), excluding auto docs-PRs. Gitea HTTP
|
||||
is mocked; never-raise -> False (INV-1).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else []
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _settings(monkeypatch):
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
|
||||
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05: a merged docs-PR (head=docs/*, base=main) is NOT counted as code-merge.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_merged_docs_pr_not_counted(monkeypatch):
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": "docs/ORCH-073-staging-log"}, "base": {"ref": "main"}},
|
||||
{"merged": False, "head": {"ref": BRANCH}, "base": {"ref": "main"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06: a merged code-PR (head==branch, base==main) IS recognised.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_merged_code_pr_recognised(monkeypatch):
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": BRANCH}, "base": {"ref": "main"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is True
|
||||
|
||||
|
||||
def test_tc06_merged_code_pr_onto_non_main_base_not_counted(monkeypatch):
|
||||
# Right head but a foreign base (not main) must NOT count.
|
||||
payload = [
|
||||
{"merged": True, "head": {"ref": BRANCH}, "base": {"ref": "develop"}},
|
||||
]
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, payload))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07: HTTP error / non-200 -> False (never-raise, conservative).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc07_non_200_is_false(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(500, []))
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
|
||||
|
||||
def test_tc07_http_exception_is_false(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise httpx.ConnectError("gitea unreachable")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
assert merge_gate.pr_already_merged("orchestrator", BRANCH) is False
|
||||
114
tests/test_orch073_regression_guard.py
Normal file
114
tests/test_orch073_regression_guard.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""ORCH-073 FR-5 — main-integrity regression guard wired into _handle_merge_verify.
|
||||
|
||||
Covers TC-13..16 / AC-3 / AC-5 / AC-6 / INV-1. Calls the under-gate handler directly
|
||||
with mocked merge_gate primitives + side effects (Plane/Telegram). Asserts the
|
||||
return contract: False == advance to `done`, True == HOLD (alert, NOT done).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch073_rg.db"))
|
||||
|
||||
from unittest.mock import MagicMock # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import stage_engine, image_freshness # noqa: E402
|
||||
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
||||
|
||||
REPO = "orchestrator"
|
||||
WI = "ORCH-073"
|
||||
BRANCH = "feature/ORCH-073-x"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _wire(monkeypatch):
|
||||
# Under-gate is in scope for the self-hosting repo; guard enabled.
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
||||
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", True)
|
||||
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #1"))
|
||||
# Silence Plane/Telegram side effects (assert on .called where relevant).
|
||||
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
monkeypatch.setattr(
|
||||
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13 (AC-6): SHA in main AND markers intact -> advance (return False), no alert.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_confirmed_and_intact_advances(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "check_main_regression", lambda r, b: (True, "markers intact (4)"))
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is False # advance to done
|
||||
assert res.alerted is False
|
||||
assert not stage_engine.set_issue_blocked.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 (AC-3): SHA NOT in main (docs-only merge) -> HOLD + alert + Blocked.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_sha_not_in_main_holds(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
||||
# Guard must never even run when SHA is not confirmed.
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run when not confirmed")),
|
||||
)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.note == "merge-not-verified-hold"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-15 (AC-5): SHA in main BUT a marker missing -> HOLD + 'main regressed' alert.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_marker_missing_holds(monkeypatch):
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
||||
monkeypatch.setattr(
|
||||
stage_engine.merge_gate, "check_main_regression",
|
||||
lambda r, b: (False, "main regressed: ORCH-067 code missing (plane_issue_link @ src/notifications.py)"),
|
||||
)
|
||||
|
||||
res = AdvanceResult()
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD, NOT done
|
||||
assert res.advanced is False
|
||||
assert res.note == "main-regressed-hold"
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-16 (INV-1): an internal verifier error -> HOLD + alert, no exception escapes.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_internal_error_holds_never_raises(monkeypatch):
|
||||
def boom(r, b, s):
|
||||
raise RuntimeError("verifier exploded")
|
||||
|
||||
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", boom)
|
||||
|
||||
res = AdvanceResult()
|
||||
# Must NOT raise.
|
||||
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
||||
|
||||
assert intervened is True # HOLD
|
||||
assert res.advanced is False
|
||||
assert res.alerted is True
|
||||
assert "merge-verify-error" in (res.note or "")
|
||||
Reference in New Issue
Block a user