feat(merge-gate): auto-rebase onto current main + re-test + serialise merges
Deterministic (no-LLM) sub-gate on the deploy-staging -> deploy edge that catches a feature branch up to the CURRENT origin/main, re-tests the combined tree, and serialises merges with a per-repo file lease — so two green parallel branches can no longer break main (self-hosting safety for the orchestrator repo). - src/merge_gate.py: branch_is_behind_main, auto_rebase_onto_main (push --force-with-lease ONLY the task branch, NEVER main), retest_branch, and a file merge-lease (atomic O_CREAT|O_EXCL, holder-aware release, stale reclaim). Strict never-raise contract; all git ops in the per-branch worktree. - src/qg/checks.py: check_branch_mergeable composes the primitives under the lease; registered in QG_CHECKS. Conditional rollout (merge_gate_enabled / merge_gate_repos, default self-hosting only). - src/stage_engine.py: sub-gate hook on deploy-staging (not a new stage). PASS -> advance; "merge-lock busy" -> DEFER (re-queue with available_at, anti-deadlock at max_concurrency=1, capped); conflict/red re-test -> rollback to development + developer retry (capped by MAX_DEVELOPER_RETRIES). Lease released on deploy->done / rollback / PR-merged webhook. - src/db.py: enqueue_job(available_at_delay_s=...) for the defer (no schema change). - src/webhooks/gitea.py: holder-aware lease release on PR-merged. - src/config.py + .env.example: ORCH_MERGE_* settings. Docs: README + adr-0006 (architect) already cover the design; CHANGELOG updated. Tests: test_merge_gate.py, test_qg_merge_gate.py, test_merge_gate_race.py, test_stage_engine.py::TestMergeGate, test_config.py, QG-registry snapshot. Full suite: 535 passed. Refs: ORCH-043 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -621,6 +621,87 @@ def check_staging_status(repo: str, work_item_id: str, branch: str | None = None
|
||||
return False, "Staging log not found (15-staging-log.md)"
|
||||
|
||||
|
||||
def _merge_gate_applies(repo: str) -> bool:
|
||||
"""Whether the merge-gate is REAL for this repo (ORCH-043, conditional rollout).
|
||||
|
||||
Mirrors the ORCH-35 conditional staging-gate. ``merge_gate_repos`` is a CSV of
|
||||
repos where the gate is enforced; when empty the gate is real ONLY for the
|
||||
self-hosting repo (``orchestrator``). Other repos -> conditional no-op.
|
||||
"""
|
||||
raw = (settings.merge_gate_repos or "").strip()
|
||||
if raw:
|
||||
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
|
||||
return (repo or "").strip().lower() in allowed
|
||||
return is_self_hosting_repo(repo)
|
||||
|
||||
|
||||
def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
|
||||
"""ORCH-043 merge-gate: validate the branch against the CURRENT origin/main
|
||||
immediately before the deployer merges its PR (deploy-staging -> deploy edge).
|
||||
|
||||
Deterministic, no LLM. Algorithm (ADR-001 §4):
|
||||
1. Conditionality: merge_gate_enabled=False -> (True, "merge-gate disabled");
|
||||
repo where the gate is not real -> (True, "merge-gate N/A for <repo>").
|
||||
2. Acquire the per-repo merge lease (NON-blocking). Busy -> (False, "merge-lock
|
||||
busy") — a SIGNAL for the engine to DEFER (not a code fault, no rollback).
|
||||
3. Double-check "behind origin/main" UNDER the lease (main may have moved while
|
||||
we waited). Not behind -> (True, "branch up-to-date with main"); lease HELD.
|
||||
4. Behind -> auto_rebase_onto_main:
|
||||
- conflict -> release lease -> (False, "rebase conflict: ...")
|
||||
- clean -> retest_branch:
|
||||
green -> (True, "rebased onto main, re-test green"); lease HELD
|
||||
red/timeout -> release lease -> (False, "re-test ... after rebase")
|
||||
5. On SUCCESS the lease is HELD until the actual merge (released on PR-merged
|
||||
webhook / deploy->done / rollback). On any FAILURE the lease is released.
|
||||
|
||||
Never-raise (AC-9): any internal error -> (False, "<reason>") with the lease
|
||||
released; an exception never escapes into advance_stage.
|
||||
"""
|
||||
# Imported lazily so qg.checks stays importable without the merge_gate deps in
|
||||
# minimal/test contexts and to avoid an import cycle surprise.
|
||||
from .. import merge_gate
|
||||
|
||||
try:
|
||||
if not settings.merge_gate_enabled:
|
||||
return True, "merge-gate disabled"
|
||||
if not _merge_gate_applies(repo):
|
||||
return True, f"merge-gate N/A for {repo}"
|
||||
|
||||
acquired, reason = merge_gate.acquire_merge_lease(repo, branch, work_item_id)
|
||||
if not acquired:
|
||||
# "merge-lock busy" -> caller defers; lease NOT held by us, nothing to release.
|
||||
return False, reason
|
||||
|
||||
try:
|
||||
# Double-check under the lease: another task may have just merged.
|
||||
if not merge_gate.branch_is_behind_main(repo, branch):
|
||||
logger.info("check_branch_mergeable: %s up-to-date with main", branch)
|
||||
return True, "branch up-to-date with main"
|
||||
|
||||
ok, rb_reason = merge_gate.auto_rebase_onto_main(repo, branch)
|
||||
if not ok:
|
||||
merge_gate.release_merge_lease(repo, branch)
|
||||
return False, rb_reason # "rebase conflict: ..."
|
||||
|
||||
ok_t, t_reason = merge_gate.retest_branch(repo, branch)
|
||||
if ok_t:
|
||||
logger.info("check_branch_mergeable: %s rebased + re-test green", branch)
|
||||
return True, "rebased onto main, re-test green"
|
||||
|
||||
merge_gate.release_merge_lease(repo, branch)
|
||||
if "timeout" in t_reason:
|
||||
return False, t_reason # "re-test timeout after <T>s" (AC-6)
|
||||
tail = t_reason.removeprefix("re-test failed: ")
|
||||
return False, f"re-test failed after rebase: {tail}"
|
||||
except Exception as e: # noqa: BLE001 - never-raise; always release on error
|
||||
merge_gate.release_merge_lease(repo, branch)
|
||||
logger.error("check_branch_mergeable inner error for %s/%s: %s", repo, branch, e)
|
||||
return False, f"merge-gate error: {e}"
|
||||
except Exception as e: # noqa: BLE001 - outer never-raise guard
|
||||
logger.error("check_branch_mergeable error for %s/%s: %s", repo, branch, e)
|
||||
return False, f"merge-gate error: {e}"
|
||||
|
||||
|
||||
# Registry for dynamic lookup by name
|
||||
QG_CHECKS = {
|
||||
"check_analysis_approved": check_analysis_approved,
|
||||
@@ -633,4 +714,5 @@ QG_CHECKS = {
|
||||
"check_tests_local": check_tests_local,
|
||||
"check_deploy_status": check_deploy_status,
|
||||
"check_staging_status": check_staging_status,
|
||||
"check_branch_mergeable": check_branch_mergeable,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user