feat(merge-verify): guarantee idempotent open code-PR before merge_pr (ORCH-082)
Close the missing invariant "by merge-verify time the branch has an open code-PR". The pipeline created a PR only on the developer path with a fresh worktree commit (launcher._ensure_pr), so a branch (e.g. after a manual main restore) could reach the deploy->done merge-verify under-gate PR-less -> merge_pr returned "no open PR" -> a FALSE HOLD (ORCH-074 incident). - merge_gate.ensure_open_pr(repo, branch) -> (status, detail): idempotent leaf-actor (never-raise). GET open PRs filtered head==branch AND base==main (identical to merge_pr/ORCH-073 FR-3 — auto docs-PR is not a code-PR) -> existed; else POST -> created; 409/422 race -> re-GET -> existed (no dup); any other error -> failed. - stage_engine._handle_merge_verify: врезка after validated_revision and BEFORE merge_pr. created|existed -> proceed; failed -> honest HOLD via new _hold_pr_create_failed (note "pr-create-failed-hold", text distinguishable from the not-merged HOLD; task stays on deploy, NO rollback). - launcher._ensure_pr delegated to ensure_open_pr (single PR-creation path, shared head==branch & base==main filter); the developer-only trigger is unchanged. - ORCH-073 protection untouched & authoritative: merge is confirmed ONLY by verify_merged_to_main (SHA-in-main) + check_main_regression. Real un-merged code still HOLDs. - Kill-switch ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED (default true); scope = merge_verify_applies (self-hosting / merge_verify_repos); non-self -> no-op; false -> ORCH-074 behaviour 1:1. No DB migration; main never push/force-push. - Append ORCH-082 marker to MAIN_REGRESSION_MARKERS (append-only convention). - conftest defaults the autocreate flag OFF (mirrors merge_verify_enabled) so unrelated deploy->done tests stay 1:1 (no network). Tests: tests/test_orch082_ensure_pr.py (TC-01..05), tests/test_orch082_merge_verify_autocreate.py (TC-06..12). Docs: README merge-verify block (ORCH-082), CHANGELOG, .env.example. Refs: ORCH-082 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -587,6 +587,101 @@ def merge_verify_applies(repo: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
|
||||
"""Guarantee an open **code-PR** (``head==branch`` AND ``base=="main"``) exists.
|
||||
|
||||
ORCH-082 (ADR-001 Р-1 / FR-1): the idempotent leaf-actor that closes the missing
|
||||
invariant "by merge-verify time the branch has an open code-PR". The pipeline used
|
||||
to create a PR ONLY on the developer path with a fresh worktree commit
|
||||
(``launcher._ensure_pr``), so a branch could reach the ``deploy -> done`` merge-verify
|
||||
under-gate with no open code-PR -> ``merge_pr`` returned ``"no open PR"`` -> a FALSE
|
||||
HOLD (the ORCH-074 incident). This actor creates/finds the code-PR ДО the
|
||||
deterministic ``merge_pr``; ORCH-073's SHA-in-main proof stays authoritative.
|
||||
|
||||
Algorithm (FR-1):
|
||||
1. ``GET …/pulls?state=open`` -> a PR with **``head.ref==branch`` AND
|
||||
``base.ref=="main"``**. The filter is **identical** to ``merge_pr``/ORCH-073
|
||||
FR-3 so both actors agree on exactly the same PR — an auto docs-PR
|
||||
(``base != main``) is NOT a code-PR (AC-6). Found -> ``("existed", "<number>")``.
|
||||
2. Otherwise ``POST …/pulls`` (``head=branch``, ``base=main``, auto title/body) ->
|
||||
``201`` -> ``("created", "<number>")``.
|
||||
3. Idempotency on a race: a ``POST`` that fails because the PR already exists
|
||||
(Gitea ``409``/``422``) -> a repeat ``GET`` (step 1) confirms the existing PR ->
|
||||
``("existed", …)``; no duplicate is created (AC-2 / FR-5).
|
||||
4. Any other HTTP/parse/network error -> ``("failed", "<reason>")``.
|
||||
|
||||
Reuses ``settings.merge_pr_timeout_s`` (same class of Gitea calls as ``merge_pr``).
|
||||
Never-raise (AC-7): any unexpected error -> ``("failed", str(e))``; the exception is
|
||||
NEVER propagated into ``_handle_merge_verify`` / ``advance_stage``.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
owner = settings.gitea_owner
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
base = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}"
|
||||
timeout = settings.merge_pr_timeout_s
|
||||
|
||||
def _find_open_code_pr() -> int | None:
|
||||
"""GET open PRs; return the code-PR number (head==branch AND base==main)."""
|
||||
resp = httpx.get(
|
||||
f"{base}/pulls", params={"state": "open"}, headers=headers, timeout=timeout
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
for pr in resp.json() or []:
|
||||
if (
|
||||
pr.get("head", {}).get("ref") == branch
|
||||
and pr.get("base", {}).get("ref") == "main"
|
||||
):
|
||||
return pr.get("number")
|
||||
return None
|
||||
|
||||
# Step 1: an open code-PR already exists -> existed (no duplicate POST).
|
||||
existing = _find_open_code_pr()
|
||||
if existing is not None:
|
||||
logger.info("ensure_open_pr: %s/%s already has open code-PR #%s", repo, branch, existing)
|
||||
return "existed", str(existing)
|
||||
|
||||
# Step 2: create the code-PR onto main.
|
||||
parts = branch.split("/")
|
||||
title = parts[-1] if parts else branch
|
||||
m = httpx.post(
|
||||
f"{base}/pulls",
|
||||
json={
|
||||
"title": f"feat: {title}",
|
||||
"head": branch,
|
||||
"base": "main",
|
||||
"body": f"Auto-created by orchestrator merge-verify for {branch}",
|
||||
},
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
if m.status_code in (200, 201):
|
||||
number = (m.json() or {}).get("number")
|
||||
logger.info("ensure_open_pr: created PR #%s for %s/%s", number, repo, branch)
|
||||
return "created", str(number)
|
||||
|
||||
# Step 3: race / already-exists (409 conflict, 422 unprocessable) -> re-GET.
|
||||
if m.status_code in (409, 422):
|
||||
again = _find_open_code_pr()
|
||||
if again is not None:
|
||||
logger.info(
|
||||
"ensure_open_pr: %s/%s PR already existed on retry (#%s, HTTP %s)",
|
||||
repo, branch, again, m.status_code,
|
||||
)
|
||||
return "existed", str(again)
|
||||
|
||||
detail = (m.text or "").strip()[:200]
|
||||
logger.warning(
|
||||
"ensure_open_pr: create failed for %s/%s: HTTP %s %s",
|
||||
repo, branch, m.status_code, detail,
|
||||
)
|
||||
return "failed", f"create PR failed: HTTP {m.status_code}"
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract (AC-7)
|
||||
logger.warning("ensure_open_pr unexpected error for %s/%s: %s", repo, branch, e)
|
||||
return "failed", f"ensure_open_pr error: {e}"
|
||||
|
||||
|
||||
def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
|
||||
"""Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API.
|
||||
|
||||
@@ -730,6 +825,7 @@ MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [
|
||||
("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"),
|
||||
("ORCH-082", "ensure_open_pr", "src/merge_gate.py"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user