Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions loopx/capabilities/issue_fix/pr_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!){
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down
7 changes: 3 additions & 4 deletions loopx/capabilities/issue_fix/pr_wait_reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
39 changes: 39 additions & 0 deletions tests/capabilities/test_pr_wait_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading