From fe8c342bf900ebb3e2be228791e4d1ece9d21eba Mon Sep 17 00:00:00 2001 From: Michael Park Date: Fri, 14 Aug 2026 13:54:27 +1000 Subject: [PATCH] fix: GitHub release repsonses can omit repository,.full_name --- scripts/run_agentic_release_project_review.py | 23 ++++++++---- ...test_run_agentic_release_project_review.py | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/scripts/run_agentic_release_project_review.py b/scripts/run_agentic_release_project_review.py index 7ad175d..1c6b820 100644 --- a/scripts/run_agentic_release_project_review.py +++ b/scripts/run_agentic_release_project_review.py @@ -448,14 +448,22 @@ def _follow_annotated_tag( def _canonical_repository(payload: dict, requested: str) -> str: - """Return the canonical owner/repo from a release API response.""" - repo = payload.get("repository") or {} + """Validate optional release repository metadata and return its identity. + + GitHub's REST release representation does not guarantee a ``repository`` + member. The endpoint itself is scoped to ``/repos/{owner}/{repo}``, and + ``_require_token_access`` has already proved that endpoint's canonical + repository with a separate repository API request. Therefore an omitted + member is normal, not ambiguous. If a member is present, it is still + validated as defense in depth. + """ + repo = payload.get("repository") + if repo is None: + return requested full = repo.get("full_name") if isinstance(repo, dict) else None if not isinstance(full, str) or not REPOSITORY_PATTERN.match(full): - raise ReleaseReviewError( - f"release response did not carry a canonical repository; expected {requested!r}" - ) - # Reject ambiguity: the canonical repository must match the requested one. + raise ReleaseReviewError("release response carried an invalid repository") + # Reject ambiguity when optional embedded repository metadata is present. if full.lower() != requested.lower(): raise ReleaseReviewError( f"release belongs to {full!r}, not the requested {requested!r}" @@ -508,7 +516,8 @@ def fetch_release( raise ReleaseReviewError("release response must be a JSON object") if payload.get("draft") is True: raise ReleaseReviewError("draft releases are not reviewed") - # Validate the canonical repository matches the request before returning. + # Release payloads may omit `repository`; endpoint-scoped access was proven + # by _require_token_access before this fetch. Validate it when present. _canonical_repository(payload, target_repository) tag = payload.get("tag_name") if not isinstance(tag, str) or not tag.strip(): diff --git a/tests/test_run_agentic_release_project_review.py b/tests/test_run_agentic_release_project_review.py index fbf8a0a..96609dc 100644 --- a/tests/test_run_agentic_release_project_review.py +++ b/tests/test_run_agentic_release_project_review.py @@ -1061,6 +1061,42 @@ def test_resolve_only_fetches_canonical_release(self) -> None: self.assertIn(f"target_repository={self.TARGET}", out) self.assertIn("external=false", out) + def test_resolve_only_accepts_github_release_without_repository_member(self) -> None: + """GitHub's release API does not guarantee embedded repository metadata. + + Repository identity is already proven by the target-scoped repository + request, while the release endpoint is scoped to that same repository. + """ + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + metadata_path = tmp_path / "release-metadata.json" + release_payload = { + "id": 42, + "tag_name": "v1.0", + "draft": False, + "target_commitish": self.SHA, + } + with ( + mock.patch.object(RUNNER, "github_request", return_value=(release_payload, {})), + mock.patch.object( + RUNNER, + "_require_token_access", + return_value={"full_name": self.TARGET}, + ), + ): + rc = RUNNER.main( + [ + "resolve-only", + "--target-repository", self.TARGET, + "--caller-repository", self.TARGET, + "--release-id", "42", + "--target-token", "t", + "--release-metadata", str(metadata_path), + ] + ) + self.assertEqual(rc, 0) + self.assertEqual(json.loads(metadata_path.read_text())["repository"], self.TARGET) + def test_resolve_only_resolves_branch_target_commitish(self) -> None: """A release whose target_commitish is a branch is resolved to a SHA.""" with tempfile.TemporaryDirectory() as tmp: