#!/usr/bin/env python3 """Discoverable, residue-surfacing, self-cleaning armed ephemeral run. The create response — not the index — is the authoritative name of a pull request this harness made, and the index is read to its last page. When either source is uncertain the run fails closed and reports the exact residue plus the exact commands that remove it, so an operator is never left guessing what was created. Arming, provenance, and push-target rules live in :mod:`hermes_handoff_arming` and are re-exported here for callers that treat armed mode as one surface. Armed mode is currently deferred: the deployed broker client speaks only ``read`` and ``create-draft``, so the POST/PATCH/DELETE probes below cannot execute against the live platform and fail closed at the client. They will be rebuilt on the broker's bounded mutation surface before armed mode is taken up. """ from __future__ import annotations from collections.abc import Sequence from typing import Any from hermes_handoff_arming import ( CONFIRMATION, REF_PREFIX, ArmingError, ArmRequest, assert_push_target_allowed, bounded_read, normalise_branch, preflight, ) from hermes_handoff_exec import Outcome, Runner, Vantage from hermes_handoff_model import EPHEMERAL, FAIL, NOT_RUN, PASS, CheckResult, CheckSpec from hermes_handoff_policy import ( EXPECTED_BASE, GITEA_CLIENT, register_ephemeral_pull, ) from hermes_handoff_rules import strict_json __all__ = [ "CONFIRMATION", "DISCOVERY_PAGE_BUDGET", "DISCOVERY_PAGE_SIZE", "REF_PREFIX", "ArmRequest", "ArmingError", "assert_push_target_allowed", "bounded_read", "normalise_branch", "preflight", "run_armed", ] DRAFT_TITLE_PREFIX = "WIP: " DISCOVERY_PAGE_SIZE = 50 DISCOVERY_PAGE_BUDGET = 20 def _spec(identifier: str, title: str) -> CheckSpec: return CheckSpec( id=f"ephemeral.{identifier}", title=title, group="ephemeral", rule="armed", steps=(), mandatory=True, scope=EPHEMERAL, ) def _result( identifier: str, title: str, status: str, reason: str, outcomes: Sequence[Outcome] = (), evidence: dict[str, Any] | None = None, ) -> CheckResult: return CheckResult( spec=_spec(identifier, title), status=status, reason=reason, evidence=evidence or {}, outcomes=list(outcomes), ) def _protected_refusal_result() -> CheckResult: accepted: list[str] = [] for candidate in ("main", "master", "refs/heads/main", "HEAD", f"{REF_PREFIX}/x"): try: assert_push_target_allowed(candidate) except ArmingError: continue accepted.append(candidate) return _result( "protected-branch-refusal", "Arming against a protected branch is refused before any network call", FAIL if accepted else PASS, f"guard accepted {accepted}" if accepted else "every protected and malformed target was refused", evidence={"accepted": accepted}, ) def _detail(outcome: Outcome) -> str: return ( outcome.error or outcome.combined[:200] or f"exit status {outcome.returncode}" ) def _pr_problems(payload: Any, request: ArmRequest) -> list[str]: if not isinstance(payload, dict): return ["pull request is not an object"] wanted = { "state": "open", "draft": True, "merged": False, "base": {"ref": EXPECTED_BASE, "sha": request.expected_base_sha}, "head": {"ref": request.ref, "sha": request.expected_head}, } problems: list[str] = [] if ( not isinstance(payload.get("number"), int) or isinstance(payload.get("number"), bool) or payload["number"] <= 0 ): problems.append("number is malformed") for field in ("state", "draft", "merged"): if ( type(payload.get(field)) is not type(wanted[field]) or payload.get(field) != wanted[field] ): problems.append(f"{field} does not match") for group, values in (("base", wanted["base"]), ("head", wanted["head"])): observed = payload.get(group) if not isinstance(observed, dict): problems.append(f"{group} is malformed") continue for field, value in values.items(): if observed.get(field) != value: problems.append(f"{group}.{field} does not match") return problems def _created_number(outcome: Outcome, request: ArmRequest) -> tuple[int, str]: """Return the number the create response itself reports. The create response is the only source that names a pull request the harness definitely made. Relying on the index alone means an index that fails or answers ambiguously leaves an unnamed, unclosed pull request behind. """ if not outcome.ok: return (0, f"pull-request creation failed: {_detail(outcome)}") try: payload = strict_json(outcome.stdout) except ValueError as exc: return (0, f"pull-request creation returned malformed JSON: {exc}") problems = _pr_problems(payload, request) if problems: return (0, f"created pull request is invalid: {', '.join(problems)}") return (payload["number"], "") def _index_pages( runner: Runner, vantage: Vantage, api: str, reserve: float ) -> tuple[list[Outcome], list[Any], str]: """Read every page of the pull index; a truncated page can hide a match.""" outcomes: list[Outcome] = [] rows: list[Any] = [] for page in range(1, DISCOVERY_PAGE_BUDGET + 1): outcome = runner.run( ( GITEA_CLIENT, "read", f"{api}/pulls?state=all&limit={DISCOVERY_PAGE_SIZE}&page={page}", ), vantage, reserve_seconds=reserve, ) outcomes.append(outcome) if not outcome.ok: return (outcomes, [], f"discovery page {page} failed: {_detail(outcome)}") try: payload = strict_json(outcome.stdout) except ValueError as exc: return (outcomes, [], f"discovery page {page} is malformed JSON: {exc}") if not isinstance(payload, list): return (outcomes, [], f"discovery page {page} did not return an array") rows.extend(payload) if len(payload) < DISCOVERY_PAGE_SIZE: return (outcomes, rows, "") return (outcomes, [], "pull-request discovery exceeded its page budget") def _residue(request: ArmRequest, numbers: Sequence[int], api: str) -> dict[str, Any]: """Name the exact residue and the exact actions that remove it by hand. The broker client exposes no close or delete operation, so the cleanup actions require the operator's own forge credentials. """ manual = [ f"close {api}/pulls/{number} (state=closed) with operator forge credentials" for number in numbers ] if not numbers: manual.append( f"{GITEA_CLIENT} read {api}/pulls?state=all&limit={DISCOVERY_PAGE_SIZE}" f" then close by hand every pull whose head ref is {request.ref}" ) manual.append( f"delete {api}/branches/{request.ref} with operator forge credentials" ) return {"residue_ref": request.ref, "manual_cleanup": manual} def _match_pulls(rows: Sequence[Any], request: ArmRequest) -> tuple[list[int], str]: matches: list[int] = [] for index, item in enumerate(rows): if ( not isinstance(item, dict) or not isinstance(item.get("head"), dict) or not isinstance(item.get("base"), dict) ): return ([], f"pull-request discovery row {index + 1} is malformed") if item["head"].get("ref") != request.ref: continue problems = _pr_problems(item, request) if problems: return ([], f"discovered pull request is invalid: {', '.join(problems)}") matches.append(item["number"]) if len(matches) != 1: return ( matches, f"expected one exact draft pull request, discovered {len(matches)}", ) return (matches, "") def _closed_pr_problems(payload: Any, request: ArmRequest) -> list[str]: """Validate the same exact PR identity with state changed to closed.""" problems = _pr_problems(payload, request) if isinstance(payload, dict) and payload.get("state") == "closed": problems = [ problem for problem in problems if problem != "state does not match" ] elif "state does not match" not in problems: problems.append("state is not closed") return problems def _cleanup( runner: Runner, vantage: Vantage, request: ArmRequest, api: str, numbers: Sequence[int], discovery_error: str = "", ) -> CheckResult: outcomes: list[Outcome] = [] problems = [discovery_error] if discovery_error else [] for number in sorted(set(numbers)): closed = runner.run( (GITEA_CLIENT, "PATCH", f"{api}/pulls/{number}", "--field", "state=closed"), vantage, ) outcomes.append(closed) if not closed.ok: problems.append(f"pull request #{number} close failed") continue try: payload = strict_json(closed.stdout) except ValueError: problems.append(f"pull request #{number} close response is malformed") continue expected = _closed_pr_problems(payload, request) if expected: problems.append(f"pull request #{number} close response does not match") deleted = runner.run( (GITEA_CLIENT, "DELETE", f"{api}/branches/{request.ref}"), vantage ) outcomes.append(deleted) if not deleted.ok: problems.append("ephemeral branch deletion failed") remaining = runner.run( ("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"), vantage ) outcomes.append(remaining) if not remaining.ok or remaining.stdout.strip(): problems.append("ephemeral branch absence could not be verified") for number in sorted(set(numbers)): observed = runner.run((GITEA_CLIENT, "read", f"{api}/pulls/{number}"), vantage) outcomes.append(observed) if not observed.ok: problems.append(f"pull request #{number} closure could not be verified") continue try: payload = strict_json(observed.stdout) except ValueError: problems.append(f"pull request #{number} verification is malformed") continue expected = _closed_pr_problems(payload, request) if expected: problems.append(f"pull request #{number} is not exactly closed") return _result( "cleanup-verified", "Every ephemeral ref and pull request is removed and the removal is verified", FAIL if problems else PASS, "; ".join(problems) if problems else "the exact branch and draft pull request are gone", outcomes, {"ref": request.ref, "pull_requests": sorted(set(numbers))}, ) def _nothing_created(branch: CheckResult, draft_reason: str) -> list[CheckResult]: """Report the two results that follow a run which created no artifact.""" return [ branch, _result( "draft-pull-request", "A draft pull request can be opened and closed", NOT_RUN, draft_reason, ), _result( "cleanup-verified", "Every ephemeral artifact is removed", NOT_RUN, "nothing was created", ), ] def run_armed( runner: Runner, vantage: Vantage, request: ArmRequest ) -> list[CheckResult]: """Create, discover, validate, and clean one exact ephemeral branch/PR.""" guard = _protected_refusal_result() branch_title = "A unique ephemeral feature branch can be pushed and removed" if guard.status != PASS: unrun = _result("feature-branch-push", branch_title, NOT_RUN, "guard failed") return [guard, *_nothing_created(unrun, "protected-ref guard failed")] api = f"/api/v1/repos/{request.repo}" reserve = min(6000.0, max(300.0, runner.command_timeout * 10)) existing = runner.run( ("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"), vantage, reserve_seconds=reserve, ) if not existing.ok or existing.stdout.strip(): reason = ( _detail(existing) if not existing.ok else "the ephemeral ref already exists" ) refused = _result("feature-branch-push", branch_title, FAIL, reason, [existing]) return [guard, *_nothing_created(refused, "branch preflight failed")] local_head = runner.run( ("git", "rev-parse", "HEAD"), vantage, reserve_seconds=reserve ) pushed = ( runner.run( ("git", "push", request.remote, f"HEAD:refs/heads/{request.ref}"), vantage, reserve_seconds=reserve, ) if local_head.ok and local_head.stdout.strip() == request.expected_head else Outcome( argv=("git", "push"), vantage=vantage.name, error="local HEAD changed after static preflight", ) ) verified = runner.run( ("git", "ls-remote", request.remote, f"refs/heads/{request.ref}"), vantage, reserve_seconds=reserve, ) branch_ok = verified.ok and verified.stdout.split() == [ request.expected_head, f"refs/heads/{request.ref}", ] branch_result = _result( "feature-branch-push", branch_title, PASS if branch_ok else FAIL, "the exact reviewed HEAD was pushed" if branch_ok else "push could not be verified exactly", [local_head, pushed, verified], {"ref": request.ref, "head": request.expected_head}, ) numbers: list[int] = [] problems: list[str] = ["branch creation was not verified"] create_outcomes: list[Outcome] = [] if branch_ok: created = runner.run( ( GITEA_CLIENT, "POST", f"{api}/pulls", "--field", f"title={DRAFT_TITLE_PREFIX}Hermes handoff acceptance ephemeral probe", "--field", f"head={request.ref}", "--field", f"base={EXPECTED_BASE}", "--field", "body=Ephemeral acceptance probe. Closed and deleted by the harness.", ), vantage, reserve_seconds=reserve, ) number, create_error = _created_number(created, request) pages, rows, index_error = _index_pages(runner, vantage, api, reserve) found, match_error = ( _match_pulls(rows, request) if not index_error else ([], "") ) create_outcomes = [created, *pages] # Either source alone names a pull request this run definitely made: the # ephemeral ref was proven absent in preflight, so any pull on it is ours. numbers = sorted({item for item in (number, *found) if item}) problems = [text for text in (index_error, match_error) if text] if number and found and found != [number]: problems.append(f"created #{number} is not the discovered {found}") if not numbers: problems.append( create_error or "no exact draft pull request was identified" ) for item in numbers: register_ephemeral_pull(item) exact = len(numbers) == 1 and not problems draft_result = _result( "draft-pull-request", "A draft pull request can be opened and closed against the ephemeral branch", PASS if exact else FAIL, f"discovered exact draft pull request #{numbers[0]}" if exact else "; ".join(problems), create_outcomes, {"pull_requests": numbers} if exact else {"pull_requests": numbers, **_residue(request, numbers, api)}, ) cleanup = _cleanup(runner, vantage, request, api, numbers, "; ".join(problems)) return [guard, branch_result, draft_result, cleanup]