diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 5ab7e830f3..16291a9a1c 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -321,14 +321,6 @@ def graphql(query: str, **fields: str | int) -> dict[str, Any]: } } } - reviews(last: 100) { - nodes { - state - body - author { login } - commit { oid } - } - } statusCheckRollup { contexts(first: 100) { nodes { @@ -356,6 +348,40 @@ def graphql(query: str, **fields: str | int) -> dict[str, Any]: """ +def fetch_complete_reviews(repo: str, number: int) -> list[dict[str, Any]]: + """Return every pull-request review normalized to the GraphQL node shape.""" + document = json.loads( + run( + [ + "gh", + "api", + "--paginate", + "--slurp", + f"repos/{repo}/pulls/{number}/reviews", + ] + ) + or "[]" + ) + if not isinstance(document, list) or any( + not isinstance(page, list) for page in document + ): + raise RuntimeError("GitHub returned malformed paginated review evidence") + reviews: list[dict[str, Any]] = [] + for page in document: + for review in page: + if not isinstance(review, dict): + raise RuntimeError("GitHub returned malformed review evidence") + reviews.append( + { + "state": review.get("state"), + "body": review.get("body"), + "author": {"login": ((review.get("user") or {}).get("login"))}, + "commit": {"oid": review.get("commit_id")}, + } + ) + return reviews + + def fetch_pr(repo: str, number: int) -> dict[str, Any]: """Fetch the pull request data required for Noema review gating.""" owner, name = split_repo(repo) @@ -363,6 +389,7 @@ def fetch_pr(repo: str, number: int) -> dict[str, Any]: pr = data.get("data", {}).get("repository", {}).get("pullRequest") if not pr: raise RuntimeError(f"PR #{number} was not found in {repo}") + pr["reviews"] = {"nodes": fetch_complete_reviews(repo, number)} return pr @@ -1779,7 +1806,7 @@ def inspect_and_review(repo: str, number: int, expected_head: str) -> int: "Noema requires an independent reviewer credential." ) if pr.get("isDraft"): - print("PR is draft; Noema review skipped.") + print("PR is draft; Noema review skipped after primary OpenCode approval.") return 0 if existing_noema_review(pr, actor): print("Current head already has a Noema review; nothing to do.") diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index e8a0dd6f59..235b4df9b8 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -735,13 +735,18 @@ def test_split_repo_and_graphql(monkeypatch): def fake_run(args, stdin=None): calls.append((args, stdin)) + if "--paginate" in args: + return "[[]]" return '{"data":{"repository":{"pullRequest":{"number":7}}}}' monkeypatch.setattr(noema, "run", fake_run) assert noema.graphql("query", owner="owner", number=7)["data"]["repository"]["pullRequest"]["number"] == 7 assert "-f" in calls[0][0] assert "-F" in calls[0][0] - assert noema.fetch_pr("owner/repo", 7) == {"number": 7} + assert noema.fetch_pr("owner/repo", 7) == { + "number": 7, + "reviews": {"nodes": []}, + } monkeypatch.setattr(noema, "graphql", lambda *args, **kwargs: {"data": {"repository": {"pullRequest": None}}}) with pytest.raises(RuntimeError, match="was not found"): @@ -2613,6 +2618,7 @@ def test_format_review_evidence_renders_only_structured_entries(): assert any("falsified" in line and "source trace passes" in line for line in lines) + def test_parse_args_and_main(monkeypatch): parsed = noema.parse_args( ["--repo", "owner/repo", "--pr-number", "9", "--expected-head", "a" * 40] @@ -2647,3 +2653,83 @@ def test_parse_args_and_main(monkeypatch): noema.main( ["--repo", "owner/repo", "--pr-number", "9", "--expected-head", "A" * 40] ) + + +def test_fetch_pr_keeps_exact_head_approval_older_than_one_hundred_reviews(monkeypatch): + """The gate must not lose a valid approval behind GitHub's review page size.""" + head_sha = "a" * 40 + approval = { + "state": "APPROVED", + "body": "Result: APPROVE", + "user": {"login": "opencode-agent"}, + "commit_id": head_sha, + } + later_comments = [ + { + "state": "COMMENTED", + "body": f"later review event {index}", + "user": {"login": "reviewer"}, + "commit_id": head_sha, + } + for index in range(100) + ] + monkeypatch.setattr( + noema, + "graphql", + lambda *args, **kwargs: { + "data": { + "repository": { + "pullRequest": make_pr( + headRefOid=head_sha, + reviews={"nodes": later_comments}, + ) + } + } + }, + ) + calls = [] + + def fake_run(args, stdin=None): + calls.append(args) + return json.dumps([[approval, *later_comments]]) + + monkeypatch.setattr(noema, "run", fake_run) + + pr = noema.fetch_pr("owner/repo", 7) + + nodes = pr["reviews"]["nodes"] + assert len(nodes) == 101 + assert nodes[0] == { + "state": "APPROVED", + "body": "Result: APPROVE", + "author": {"login": "opencode-agent"}, + "commit": {"oid": head_sha}, + } + assert calls == [ + [ + "gh", + "api", + "--paginate", + "--slurp", + "repos/owner/repo/pulls/7/reviews", + ] + ] + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ("{}", "malformed paginated review evidence"), + ("[[null]]", "malformed review evidence"), + ], +) +def test_fetch_complete_reviews_fails_closed_on_malformed_evidence( + monkeypatch, + payload, + message, +): + """Malformed review pages must not become an empty approval history.""" + monkeypatch.setattr(noema, "run", lambda *args, **kwargs: payload) + + with pytest.raises(RuntimeError, match=message): + noema.fetch_complete_reviews("owner/repo", 7)