diff --git a/loopx/capabilities/issue_fix/pr_lifecycle.py b/loopx/capabilities/issue_fix/pr_lifecycle.py index 75785c5735..037afc6a6b 100644 --- a/loopx/capabilities/issue_fix/pr_lifecycle.py +++ b/loopx/capabilities/issue_fix/pr_lifecycle.py @@ -66,6 +66,7 @@ } PASSING_CHECK_STATES = {"NEUTRAL", "SKIPPED", "SUCCESS"} BRANCH_REPLAN_MERGE_STATES = {"BEHIND", "DIRTY"} +_VERIFIED_REQUESTED_REPOSITORY_FIELD = "_loopx_verified_requested_repository" _REVIEW_RESPONSE_QUERY = """ query($owner:String!,$name:String!,$number:Int!){ @@ -909,6 +910,37 @@ def fetch_github_pr_lifecycle_payload( payload = json.loads(result.stdout) if not isinstance(payload, dict): raise ValueError("GitHub PR lifecycle fetch must return a JSON object") + provider_reference = normalise_github_issue_reference( + repo=repo, + issue_ref=f"pull_{number}", + url=str(payload.get("url") or ""), + ) + if ( + provider_reference.get("kind") != "pull_request" + or provider_reference.get("number") != number + ): + raise ValueError("GitHub PR lifecycle provider identity mismatch") + if str(provider_reference["repo"]).lower() != repo.lower(): + redirect = subprocess.run( + ["gh", "repo", "view", repo, "--json", "nameWithOwner"], + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout_seconds, + ) + if redirect.returncode != 0: + raise RuntimeError("GitHub repository redirect verification failed") + redirect_payload = json.loads(redirect.stdout) + canonical_repo = ( + str(redirect_payload.get("nameWithOwner") or "").strip().lower() + if isinstance(redirect_payload, dict) + else "" + ) + if canonical_repo != str(provider_reference["repo"]).lower(): + raise ValueError("GitHub PR lifecycle provider identity mismatch") + payload[_VERIFIED_REQUESTED_REPOSITORY_FIELD] = canonical_repo if _upper_label(payload.get("reviewDecision")) == "CHANGES_REQUESTED": payload.update( _fetch_github_review_response_metadata( @@ -921,6 +953,31 @@ def fetch_github_pr_lifecycle_payload( return payload +def validate_github_pr_provider_identity( + reference: Mapping[str, Any], provider_payload: Mapping[str, Any] +) -> dict[str, Any]: + """Validate one exact PR response, including a provider-verified repo rename.""" + + expected_repo = str(reference.get("repo") or "").strip().lower() + expected_number = reference.get("number") + provider_reference = normalise_github_issue_reference( + repo=expected_repo, + issue_ref=f"pull_{expected_number}", + url=str(provider_payload.get("url") or ""), + ) + if ( + provider_reference.get("kind") != "pull_request" + or provider_reference.get("number") != expected_number + ): + raise ValueError("PR dependency provider identity mismatch") + provider_repo = str(provider_reference.get("repo") or "").strip().lower() + if provider_repo != expected_repo and str( + provider_payload.get(_VERIFIED_REQUESTED_REPOSITORY_FIELD) or "" + ).strip().lower() != provider_repo: + raise ValueError("PR dependency provider identity mismatch") + return provider_reference + + def build_issue_fix_pr_lifecycle_monitor_packet( *, repo: str = "public_repo_fixture", diff --git a/loopx/capabilities/issue_fix/pr_wait_reconcile.py b/loopx/capabilities/issue_fix/pr_wait_reconcile.py index c6f73d0c3d..27bbd65b0a 100644 --- a/loopx/capabilities/issue_fix/pr_wait_reconcile.py +++ b/loopx/capabilities/issue_fix/pr_wait_reconcile.py @@ -77,10 +77,9 @@ def _reconcile_locked( try: metadata = pr_lifecycle.fetch_github_pr_lifecycle_payload(reference, timeout_seconds=fetch_timeout_seconds, compact=True) - # A provider redirect or malformed success must never attest another - # repository's identically numbered dependency. No default OPEN. - if str(metadata.get("url") or "").lower().rstrip("/") != target["url"].lower(): - raise ValueError("PR dependency provider identity mismatch") + # A verified GitHub rename may preserve this dependency through an + # alias; a malformed response or unrelated same-number PR may not. + pr_lifecycle.validate_github_pr_provider_identity(reference, metadata) state = str(metadata.get("state") or "").upper() if state not in {"OPEN", "CLOSED", "MERGED"}: raise ValueError("PR dependency provider state unavailable") diff --git a/tests/capabilities/test_issue_fix_pr_lifecycle_review_response.py b/tests/capabilities/test_issue_fix_pr_lifecycle_review_response.py index 600ea6092a..44afb0b426 100644 --- a/tests/capabilities/test_issue_fix_pr_lifecycle_review_response.py +++ b/tests/capabilities/test_issue_fix_pr_lifecycle_review_response.py @@ -15,6 +15,7 @@ def _changes_requested_payload() -> dict[str, object]: return { "state": "OPEN", + "url": "https://github.com/example/project/pull/3258", "reviewDecision": "CHANGES_REQUESTED", "mergeStateStatus": "BLOCKED", "headRefOid": "b" * 40, @@ -164,3 +165,32 @@ def test_review_response_fetch_timeout_fails_closed() -> None: provider_payload=payload, ) assert packet["transition"]["action_kind"] == "issue_fix_review_changes_replan" + + +def test_fetch_metadata_verifies_repository_redirect() -> None: + lifecycle = { + "state": "MERGED", + "mergedAt": "2026-10-01T00:00:00Z", + "url": "https://github.com/new-owner/project/pull/3258", + } + redirect = {"nameWithOwner": "new-owner/project"} + responses = [ + subprocess.CompletedProcess([], 0, json.dumps(lifecycle), ""), + subprocess.CompletedProcess([], 0, json.dumps(redirect), ""), + ] + + with patch( + "loopx.capabilities.issue_fix.pr_lifecycle.subprocess.run", + side_effect=responses, + ) as run: + payload = fetch_github_pr_lifecycle_payload( + {"repo": "old-owner/project", "number": 3258}, + timeout_seconds=5, + compact=True, + ) + + assert run.call_count == 2 + assert run.call_args_list[1].args[0] == [ + "gh", "repo", "view", "old-owner/project", "--json", "nameWithOwner", + ] + assert payload["_loopx_verified_requested_repository"] == "new-owner/project" diff --git a/tests/capabilities/test_pr_wait_observation.py b/tests/capabilities/test_pr_wait_observation.py index 5e6ea973ba..42f48b3590 100644 --- a/tests/capabilities/test_pr_wait_observation.py +++ b/tests/capabilities/test_pr_wait_observation.py @@ -98,6 +98,45 @@ def fetch(reference, **kwargs): assert not (project / "host-started").exists() +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_executing_turn_resumes_verified_repository_redirect( + tmp_path, monkeypatch, provider, +): + from loopx.capabilities.issue_fix import pr_lifecycle + from loopx.rollout_event_log import load_rollout_events, rollout_event_log_path + + registry, runtime, project, todo_id, argv = _native_wait_turn( + tmp_path, monkeypatch, provider, + ) + calls = [] + + def fetch(reference, **kwargs): + calls.append(reference) + return { + "state": "MERGED", + "mergedAt": "2026-10-01T00:00:00Z", + "url": "https://github.com/new-owner/repo/pull/42", + "_loopx_verified_requested_repository": "new-owner/repo", + } + + monkeypatch.setattr(pr_lifecycle, "fetch_github_pr_lifecycle_payload", fetch) + _, result = _invoke_turn([*argv, "--turn-instance-id", "renamed-repository"]) + + after = list_goal_todos( + registry_path=registry, goal_id="goal-a", todo_id=todo_id, + )["todo"] + assert after["resume_ready"] is True, result + assert _observation(result)["merged_count"] == 1 + events = load_rollout_events(rollout_event_log_path(runtime, "goal-a")) + merge = next(event for event in events if event["event_kind"] == "pr_merge") + assert merge["code_refs"]["pr_ref"] == "new-owner/repo#42" + assert merge["source_refs"] == [ + {"kind": "pull_request", "ref": "owner/repo#42"}, + ] + assert len(calls) == 1 + assert not (project / "host-started").exists() + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_turn_observes_dependencies_synchronized_during_turn_start(tmp_path, monkeypatch, provider): from loopx.capabilities.issue_fix import pr_lifecycle