"""MERGING handler — calls Forgejo's merge endpoint + maps the 6-response-shape table to state machine transitions. Per plan v9 non-blocking retry: MERGING stays in MERGING across ticks. The handler: 1. Checks if workflows.merging_retry_next_attempt_at is in the future (if so, skip this tick). 2. Calls the merge callback (production: Forgejo POST /pulls/{n}/merge). 3. Maps the response: 200 success → MERGED 409 conflict → IMPLEMENTING(tier_last_succeeded) with reason='post-approval-base-conflict' 403 forbidden → STUCK (branch protection) 422 missing-checks → AWAITING_CI (rare race) 5xx → stay MERGING, retry_count++, backoff STUCK at retry_count >= 5 404 → check PR state → MERGED (extern-merged) OR ABANDONED (extern-closed) Forgejo HTTP injected via two callbacks so tests don't need a live instance. Production wires to existing _claim_runtime helpers. """ from __future__ import annotations import json import logging from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import text from sqlalchemy.engine import Engine from ..db.session import session_scope logger = logging.getLogger(__name__) # Per plan v9: STUCK after 5 retries on transient errors. MAX_MERGE_RETRIES = 5 # Backoff: 2^retry_count seconds, capped at 60s. MAX_BACKOFF_S = 60.0 @dataclass class MergeResponse: """Normalized Forgejo merge response shape. The callback returns this rather than raw HTTP details so the handler can route deterministically. Maps to plan v9's 6-response table. """ status_code: int pr_state: str | None = None # 'merged' | 'open' | 'closed' | None error_message: str | None = None # Forgejo merge call callback. Returns a MergeResponse summarizing # the HTTP attempt + (for 404 paths) the PR's actual state. MergeCallback = Callable[[str, str, int], MergeResponse] @dataclass class MergingHandlerReport: """Per-tick summary of the MERGING handler.""" workflows_processed: int = 0 workflows_merged: int = 0 workflows_back_to_implementing: int = 0 workflows_stuck: int = 0 workflows_awaiting_ci: int = 0 workflows_abandoned: int = 0 workflows_retrying: int = 0 # retry scheduled for future tick transitions: list[dict] = field(default_factory=list) def run_merging_tick( engine: Engine, *, merge: MergeCallback, owner: str, repo: str, max_retries: int = MAX_MERGE_RETRIES, ) -> MergingHandlerReport: """One MERGING-handling sweep. For each workflow with current_state='MERGING' where merging_retry_next_attempt_at is null or in the past: 1. Call the merge callback. 2. Apply the response → state transition (via direct UPDATE because MERGING transitions don't fit the outcome-mapper protocol). 3. Insert a controller_events row. """ report = MergingHandlerReport() now = datetime.now(timezone.utc) with session_scope(engine) as session: rows = session.execute( text( "SELECT workflow_id, entity_number, tier_last_succeeded, " " merging_retry_count, merging_retry_next_attempt_at " " FROM workflows " " WHERE current_state = 'MERGING' " " AND owner = :owner AND repo = :repo " " AND kind = 'pr' " " AND (merging_retry_next_attempt_at IS NULL " " OR merging_retry_next_attempt_at <= :now)" ), {"owner": owner, "repo": repo, "now": now}, ).all() for r in rows: report.workflows_processed += 1 transition = _process_merging_row( session, r, merge=merge, owner=owner, repo=repo, max_retries=max_retries, now=now, ) report.transitions.append(transition) kind = transition.get("kind", "unknown") if kind == "merged": report.workflows_merged += 1 elif kind == "implementing": report.workflows_back_to_implementing += 1 elif kind == "stuck": report.workflows_stuck += 1 elif kind == "awaiting_ci": report.workflows_awaiting_ci += 1 elif kind == "abandoned": report.workflows_abandoned += 1 elif kind == "retry": report.workflows_retrying += 1 return report # ─── per-row processing ─────────────────────────────────────────────── def _process_merging_row( session, row, *, merge: MergeCallback, owner: str, repo: str, max_retries: int, now: datetime, ) -> dict: """Call the merge callback + apply the response. Returns a transition record (used for telemetry + reporting).""" try: resp = merge(owner, repo, row.entity_number) except Exception as exc: # noqa: BLE001 — treat as transient # Wrap in a synthetic 503 so the retry logic kicks in. logger.warning( "merge callback raised for PR #%s: %s; treating as 5xx", row.entity_number, exc, ) resp = MergeResponse( status_code=503, error_message=f"callback raised: {exc}" ) status = resp.status_code # ── 200: merged ── if status == 200: _transition( session, row.workflow_id, "MERGING", "MERGED", now, event="merge_ok", payload={"reason": "merged", "http_status": status}, ) return {"kind": "merged", "workflow_id": row.workflow_id} # ── 409: conflict (base advanced post-approval) ── if status == 409: _transition( session, row.workflow_id, "MERGING", "IMPLEMENTING", now, event="merge_base_conflict", tier=row.tier_last_succeeded, payload={ "reason": "post-approval-base-conflict", "http_status": status, }, ) # Reset merging retry counter for next time. _reset_merging_retries(session, row.workflow_id) return {"kind": "implementing", "workflow_id": row.workflow_id} # ── 403: branch protection ── if status == 403: _transition( session, row.workflow_id, "MERGING", "STUCK", now, event="merge_branch_protection_blocked", payload={ "reason": "branch-protection", "http_status": status, "error": resp.error_message, }, ) return {"kind": "stuck", "workflow_id": row.workflow_id} # ── 422: CI required-status missing (race) ── if status == 422: _transition( session, row.workflow_id, "MERGING", "AWAITING_CI", now, event="merge_ci_required_missing", payload={ "reason": "ci-required-status-missing", "http_status": status, }, ) _reset_merging_retries(session, row.workflow_id) return {"kind": "awaiting_ci", "workflow_id": row.workflow_id} # ── 404: PR closed externally (merged or abandoned) ── if status == 404: # Use the response's pr_state to determine which side. # If the callback didn't populate it, default to ABANDONED # (conservative — operator can re-open via controller-cli). if resp.pr_state == "merged": _transition( session, row.workflow_id, "MERGING", "MERGED", now, event="merge_external_action", payload={"reason": "externally-merged", "http_status": status}, ) return {"kind": "merged", "workflow_id": row.workflow_id} _transition( session, row.workflow_id, "MERGING", "ABANDONED", now, event="merge_external_action", payload={ "reason": "externally-closed-or-removed", "http_status": status, "pr_state": resp.pr_state, }, ) return {"kind": "abandoned", "workflow_id": row.workflow_id} # ── 5xx / anything else: transient. Retry until exhausted ── next_retry_count = (row.merging_retry_count or 0) + 1 if next_retry_count >= max_retries: _transition( session, row.workflow_id, "MERGING", "STUCK", now, event="merge_retry_exhausted", payload={ "reason": "merge-retry-exhausted", "http_status": status, "retries": next_retry_count, "error": resp.error_message, }, ) return {"kind": "stuck", "workflow_id": row.workflow_id} backoff = min(MAX_BACKOFF_S, float(2 ** next_retry_count)) next_at = now + timedelta(seconds=backoff) session.execute( text( "UPDATE workflows SET " " merging_retry_count = :count, " " merging_retry_next_attempt_at = :next_at " "WHERE workflow_id = :wf_id" ), { "count": next_retry_count, "next_at": next_at, "wf_id": row.workflow_id, }, ) # Record the retry attempt as a controller_event (not a transition). session.execute( text( "INSERT INTO controller_events " "(workflow_id, ts, event_type, payload, " " forgejo_write_pending, replay_attempts) " "VALUES (:wf_id, :ts, 'merge-retry-scheduled', :payload, 0, 0)" ), { "wf_id": row.workflow_id, "ts": now, "payload": json.dumps({ "http_status": status, "retry_count": next_retry_count, "next_attempt_at": next_at.isoformat(), "backoff_s": backoff, "error": resp.error_message, }), }, ) return { "kind": "retry", "workflow_id": row.workflow_id, "retry_count": next_retry_count, "next_attempt_at": next_at.isoformat(), } # ─── helpers ────────────────────────────────────────────────────────── def _transition( session, workflow_id: int, from_state: str, to_state: str, now: datetime, *, event: str, tier: int | None = None, payload: dict[str, Any] | None = None, ) -> None: """Write a workflow state transition + controller_events row.""" if tier is not None: session.execute( text( "UPDATE workflows SET " " current_state = :to, current_tier = :tier, " " last_transition_at = :now, entered_state_at = :now " "WHERE workflow_id = :wf_id" ), {"to": to_state, "tier": tier, "now": now, "wf_id": workflow_id}, ) else: session.execute( text( "UPDATE workflows SET " " current_state = :to, " " last_transition_at = :now, entered_state_at = :now " "WHERE workflow_id = :wf_id" ), {"to": to_state, "now": now, "wf_id": workflow_id}, ) session.execute( text( "INSERT INTO controller_events " "(workflow_id, ts, event_type, from_state, to_state, payload, " " forgejo_write_pending, replay_attempts) " "VALUES (:wf_id, :ts, 'transition', :from_state, :to_state, " " :payload, 0, 0)" ), { "wf_id": workflow_id, "ts": now, "from_state": from_state, "to_state": to_state, "payload": json.dumps({**(payload or {}), "event": event}), }, ) def _reset_merging_retries(session, workflow_id: int) -> None: """Clear the merging retry counter + backoff after a non-retry response (success / conflict / 422).""" session.execute( text( "UPDATE workflows SET " " merging_retry_count = 0, " " merging_retry_next_attempt_at = NULL " "WHERE workflow_id = :wf_id" ), {"wf_id": workflow_id}, ) __all__ = [ "MAX_BACKOFF_S", "MAX_MERGE_RETRIES", "MergeCallback", "MergeResponse", "MergingHandlerReport", "run_merging_tick", ]