From 91c182930be43688543575107bf9b997cfe92752 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Fri, 13 Nov 2026 10:20:49 +0530 Subject: [PATCH 1/2] fix(pr-management): verify CI status check rollup belongs to PR head (part 1 of #2034) * Query commit OID under latest: commits(last: 1) in cr-open.graphql and cr-pr.graphql * In data.py from_node(), verify commit.oid == headRefOid case-insensitively and fail closed (discard rollup) on mismatch or missing OID * When discarded, disposition.py blocks APPROVE with 'CI is not reported' * Add regression tests in test_data.py and test_decisions.py Refs #2034 --- .../src/pr_management/code_review/data.py | 12 +- .../src/vetted_ops/queries/cr-open.graphql | 1 + .../src/vetted_ops/queries/cr-pr.graphql | 1 + .../src/pr_management/code_review/data.py | 12 +- .../tests/code_review/builders.py | 3 +- .../tests/code_review/test_data.py | 138 ++++++++++++++++++ .../tests/code_review/test_decisions.py | 38 ++++- .../src/vetted_ops/queries/cr-open.graphql | 1 + .../src/vetted_ops/queries/cr-pr.graphql | 1 + 9 files changed, 203 insertions(+), 4 deletions(-) diff --git a/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/data.py b/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/data.py index c6c2ca765..f18645ace 100644 --- a/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/data.py +++ b/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/data.py @@ -205,7 +205,17 @@ def _commit_authors(commit: dict[str, Any]) -> tuple[str, ...]: def from_node(node: dict[str, Any]) -> PR: latest = _nodes(node.get("latest")) - rollup = ((latest[-1].get("commit") if latest else None) or {}).get("statusCheckRollup") or {} + latest_commit = (latest[-1].get("commit") if latest else None) or {} + head_sha = str(node.get("headRefOid") or "").strip().lower() + commit_oid = str(latest_commit.get("oid") or "").strip().lower() + + # Golden rule 8: the CI status check rollup must strictly belong to the PR's head commit. + # If the commit OID is missing, empty, or does not match headRefOid (e.g. after a force-push + # or a stale GraphQL connection edge), fail closed by discarding the rollup. + if head_sha and commit_oid and head_sha == commit_oid: + rollup = latest_commit.get("statusCheckRollup") or {} + else: + rollup = {} users, teams = _requests(node) threads = _nodes(node.get("reviewThreads")) reviews_raw = _nodes(node.get("reviews")) or _nodes(node.get("latestReviews")) diff --git a/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/queries/cr-open.graphql b/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/queries/cr-open.graphql index 35f330103..71fa61e90 100644 --- a/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/queries/cr-open.graphql +++ b/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/queries/cr-open.graphql @@ -71,6 +71,7 @@ query($owner: String!, $repo: String!, $endCursor: String) { latest: commits(last: 1) { nodes { commit { + oid statusCheckRollup { state contexts(first: 50) { diff --git a/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/queries/cr-pr.graphql b/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/queries/cr-pr.graphql index 8d2a8eba0..da48007f1 100644 --- a/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/queries/cr-pr.graphql +++ b/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/queries/cr-pr.graphql @@ -103,6 +103,7 @@ query($owner: String!, $repo: String!, $number: Int!) { latest: commits(last: 1) { nodes { commit { + oid statusCheckRollup { state contexts(first: 100) { diff --git a/tools/pr-management/src/pr_management/code_review/data.py b/tools/pr-management/src/pr_management/code_review/data.py index c6c2ca765..f18645ace 100644 --- a/tools/pr-management/src/pr_management/code_review/data.py +++ b/tools/pr-management/src/pr_management/code_review/data.py @@ -205,7 +205,17 @@ def _commit_authors(commit: dict[str, Any]) -> tuple[str, ...]: def from_node(node: dict[str, Any]) -> PR: latest = _nodes(node.get("latest")) - rollup = ((latest[-1].get("commit") if latest else None) or {}).get("statusCheckRollup") or {} + latest_commit = (latest[-1].get("commit") if latest else None) or {} + head_sha = str(node.get("headRefOid") or "").strip().lower() + commit_oid = str(latest_commit.get("oid") or "").strip().lower() + + # Golden rule 8: the CI status check rollup must strictly belong to the PR's head commit. + # If the commit OID is missing, empty, or does not match headRefOid (e.g. after a force-push + # or a stale GraphQL connection edge), fail closed by discarding the rollup. + if head_sha and commit_oid and head_sha == commit_oid: + rollup = latest_commit.get("statusCheckRollup") or {} + else: + rollup = {} users, teams = _requests(node) threads = _nodes(node.get("reviewThreads")) reviews_raw = _nodes(node.get("reviews")) or _nodes(node.get("latestReviews")) diff --git a/tools/pr-management/tests/code_review/builders.py b/tools/pr-management/tests/code_review/builders.py index e52338d23..ea2b423d1 100644 --- a/tools/pr-management/tests/code_review/builders.py +++ b/tools/pr-management/tests/code_review/builders.py @@ -106,9 +106,10 @@ def node( "nodes": [ { "commit": { + "oid": head, "statusCheckRollup": None if rollup is None - else {"state": rollup, "contexts": {"nodes": checks}} + else {"state": rollup, "contexts": {"nodes": checks}}, } } ] diff --git a/tools/pr-management/tests/code_review/test_data.py b/tools/pr-management/tests/code_review/test_data.py index dffd2d2d1..3f707cc1a 100644 --- a/tools/pr-management/tests/code_review/test_data.py +++ b/tools/pr-management/tests/code_review/test_data.py @@ -41,3 +41,141 @@ def test_the_path_commits_save_name_is_one_vetted_ops_accepts(path: str) -> None def test_long_paths_that_share_a_stem_get_different_names() -> None: assert data.slug(LONG) != data.slug(LONG.replace("state.py", "other.py")) + + +def test_from_node_accepts_rollup_when_commit_oid_matches_head_ref_oid() -> None: + node = { + "number": 1, + "headRefOid": "3ac12395", + "latest": { + "nodes": [ + { + "commit": { + "oid": "3ac12395", + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "build", + "conclusion": "SUCCESS", + "status": "COMPLETED", + } + ] + }, + }, + } + } + ] + }, + } + pr = data.from_node(node) + assert pr.head_sha == "3ac12395" + assert pr.rollup_state == "SUCCESS" + assert len(pr.contexts) == 1 + assert pr.contexts[0].name == "build" + assert pr.contexts[0].conclusion == "SUCCESS" + + +def test_from_node_discards_rollup_when_commit_oid_mismatches_head_ref_oid() -> None: + # Simulates Issue #2034: headRefOid is 3ac12395 after a force-push, + # but GraphQL returns a stale rollup on prior commit 4eb3567c. + node = { + "number": 272, + "headRefOid": "3ac12395", + "latest": { + "nodes": [ + { + "commit": { + "oid": "4eb3567c", + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "build", + "conclusion": "SUCCESS", + "status": "COMPLETED", + } + ] + }, + }, + } + } + ] + }, + } + pr = data.from_node(node) + assert pr.head_sha == "3ac12395" + assert pr.rollup_state is None + assert pr.contexts == () + assert pr.pending_not_required == () + + +def test_from_node_discards_rollup_when_commit_oid_missing() -> None: + node = { + "number": 1, + "headRefOid": "3ac12395", + "latest": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": {"nodes": []}, + }, + } + } + ] + }, + } + pr = data.from_node(node) + assert pr.rollup_state is None + assert pr.contexts == () + + +def test_from_node_discards_rollup_when_head_ref_oid_missing() -> None: + node = { + "number": 1, + "latest": { + "nodes": [ + { + "commit": { + "oid": "3ac12395", + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": {"nodes": []}, + }, + } + } + ] + }, + } + pr = data.from_node(node) + assert pr.rollup_state is None + assert pr.contexts == () + + +def test_from_node_accepts_rollup_case_insensitively() -> None: + node = { + "number": 1, + "headRefOid": "3AC12395", + "latest": { + "nodes": [ + { + "commit": { + "oid": " 3ac12395 ", + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": {"nodes": []}, + }, + } + } + ] + }, + } + pr = data.from_node(node) + assert pr.head_sha == "3AC12395" + assert pr.rollup_state == "SUCCESS" diff --git a/tools/pr-management/tests/code_review/test_decisions.py b/tools/pr-management/tests/code_review/test_decisions.py index d1627729f..90f48671e 100644 --- a/tools/pr-management/tests/code_review/test_decisions.py +++ b/tools/pr-management/tests/code_review/test_decisions.py @@ -28,7 +28,7 @@ import pytest -from pr_management.code_review import codeowners, deps, disposition, reviewers +from pr_management.code_review import codeowners, data, deps, disposition, reviewers from .builders import NOW, ago, review from .builders import pr as build_pr @@ -468,3 +468,39 @@ def test_markers_split_environments() -> None: } ) assert result["evidence"] == {"uninstallable_in": ["py39"]} + + +def test_force_pushed_pr_with_stale_ci_rollup_fails_closed_to_comment() -> None: + # Issue #2034: PR head changed to 3ac12395, but GraphQL returned stale SUCCESS rollup on 4eb3567c. + node = { + "number": 272, + "headRefOid": "3ac12395", + "latest": { + "nodes": [ + { + "commit": { + "oid": "4eb3567c", + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "tests", + "conclusion": "SUCCESS", + "status": "COMPLETED", + } + ] + }, + }, + } + } + ] + }, + } + pr = data.from_node(node) + assert pr.rollup_state is None + # With clean findings, APPROVE is blocked because CI is unverified (fail-closed Golden rule 8). + result = _pick_findings([], ci_state=pr.rollup_state) + assert result["disposition"] == "COMMENT" + assert "CI is not reported" in result["reason"] diff --git a/tools/vetted-ops/src/vetted_ops/queries/cr-open.graphql b/tools/vetted-ops/src/vetted_ops/queries/cr-open.graphql index 35f330103..71fa61e90 100644 --- a/tools/vetted-ops/src/vetted_ops/queries/cr-open.graphql +++ b/tools/vetted-ops/src/vetted_ops/queries/cr-open.graphql @@ -71,6 +71,7 @@ query($owner: String!, $repo: String!, $endCursor: String) { latest: commits(last: 1) { nodes { commit { + oid statusCheckRollup { state contexts(first: 50) { diff --git a/tools/vetted-ops/src/vetted_ops/queries/cr-pr.graphql b/tools/vetted-ops/src/vetted_ops/queries/cr-pr.graphql index 8d2a8eba0..da48007f1 100644 --- a/tools/vetted-ops/src/vetted_ops/queries/cr-pr.graphql +++ b/tools/vetted-ops/src/vetted_ops/queries/cr-pr.graphql @@ -103,6 +103,7 @@ query($owner: String!, $repo: String!, $number: Int!) { latest: commits(last: 1) { nodes { commit { + oid statusCheckRollup { state contexts(first: 100) { From cd9cf50c728697ea811555371bb30e1536dd7579 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Fri, 13 Nov 2026 11:33:42 +0530 Subject: [PATCH 2/2] fix(pr-management): handle query skew on missing OID and clarify test scoping - In data.py, distinguish missing commit OID from mismatched OID when a rollup exists - Flag ci_outdated_query on PR so disposition can gate with an explicit upgrade instruction rather than leaving maintainers guessing why CI is unreported - Clarify test docstrings and naming to reflect Direction 1 (lagging commit edge guard) without overstating coverage of same-head stale rollups - Sync dual-tree mirrors under plugins/magpie-security-tools --- .../src/pr_management/code_review/cli.py | 1 + .../src/pr_management/code_review/data.py | 13 +++++- .../pr_management/code_review/disposition.py | 4 ++ .../src/pr_management/code_review/cli.py | 1 + .../src/pr_management/code_review/data.py | 13 +++++- .../pr_management/code_review/disposition.py | 4 ++ .../tests/code_review/test_data.py | 10 +++-- .../tests/code_review/test_decisions.py | 40 ++++++++++++++++++- 8 files changed, 77 insertions(+), 9 deletions(-) diff --git a/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/cli.py b/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/cli.py index 176a146d1..a8a63b691 100644 --- a/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/cli.py +++ b/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/cli.py @@ -443,6 +443,7 @@ def _decide( merge_method=pr.default_merge_method, pending_not_required=pr.pending_not_required, viewer_known=bool(pr.viewer), + ci_outdated_query=pr.ci_outdated_query, ) diff --git a/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/data.py b/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/data.py index f18645ace..34e82f591 100644 --- a/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/data.py +++ b/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/data.py @@ -110,6 +110,7 @@ class PR: default_merge_method: str | None = None auto_merge_enabled: bool = False pending_not_required: tuple[str, ...] = () + ci_outdated_query: bool = False #: The last two commits with parents, verified signer and linked author logins (fixup checks). head_commits: tuple[dict[str, Any], ...] = () extra: dict[str, Any] = field(default_factory=dict) @@ -210,10 +211,17 @@ def from_node(node: dict[str, Any]) -> PR: commit_oid = str(latest_commit.get("oid") or "").strip().lower() # Golden rule 8: the CI status check rollup must strictly belong to the PR's head commit. - # If the commit OID is missing, empty, or does not match headRefOid (e.g. after a force-push - # or a stale GraphQL connection edge), fail closed by discarding the rollup. + # If commit OID matches headRefOid, the rollup is accepted. + # If commit OID mismatches headRefOid (e.g. lagging commit edge), fail closed by discarding. + # If statusCheckRollup exists but commit OID is missing, the installed vetted-ops queries are + # out of date (older vetted-ops without `oid` under `latest`). Fail closed to protect the gate, + # but flag `ci_outdated_query` so disposition can give an actionable upgrade instruction. + ci_outdated_query = False if head_sha and commit_oid and head_sha == commit_oid: rollup = latest_commit.get("statusCheckRollup") or {} + elif latest_commit.get("statusCheckRollup") and not commit_oid: + rollup = {} + ci_outdated_query = True else: rollup = {} users, teams = _requests(node) @@ -273,6 +281,7 @@ def from_node(node: dict[str, Any]) -> PR: rollup_state=rollup.get("state") if rollup else None, contexts=_contexts(rollup), pending_not_required=_pending_not_required(rollup) if rollup else (), + ci_outdated_query=ci_outdated_query, head_commits=_head_commits(node), node_id=node.get("id"), thread_details=tuple(threads), diff --git a/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/disposition.py b/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/disposition.py index 800924826..ad152bc0a 100644 --- a/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/disposition.py +++ b/plugins/magpie-security-tools/tools/pr-management/src/pr_management/code_review/disposition.py @@ -102,6 +102,7 @@ def pick( merge_method: str | None = None, pending_not_required: tuple[str, ...] | list[str] = (), viewer_known: bool = True, + ci_outdated_query: bool = False, ) -> dict[str, Any]: resolve_first = resolve_first or [] counts = Counter(str(f.get("severity") or "").lower() for f in findings) @@ -142,6 +143,9 @@ def pick( elif ci_state == "EXPECTED": gates.append("CI is waiting for workflow approval — the project's real CI has not run") ci_action = "approve the waiting workflow runs (pr-management-triage), then re-run disposition" + elif ci_outdated_query: + gates.append("CI rollup cannot be verified — vetted-ops is out of date (latest commit lacks OID)") + ci_action = "upgrade vetted-ops to update GraphQL queries, then re-run disposition" elif ci_state != "SUCCESS" and not held_by_auto_merge: gates.append(f"CI is {ci_state or 'not reported'}") if ci_state == "PENDING" and not real_ci: diff --git a/tools/pr-management/src/pr_management/code_review/cli.py b/tools/pr-management/src/pr_management/code_review/cli.py index 176a146d1..a8a63b691 100644 --- a/tools/pr-management/src/pr_management/code_review/cli.py +++ b/tools/pr-management/src/pr_management/code_review/cli.py @@ -443,6 +443,7 @@ def _decide( merge_method=pr.default_merge_method, pending_not_required=pr.pending_not_required, viewer_known=bool(pr.viewer), + ci_outdated_query=pr.ci_outdated_query, ) diff --git a/tools/pr-management/src/pr_management/code_review/data.py b/tools/pr-management/src/pr_management/code_review/data.py index f18645ace..34e82f591 100644 --- a/tools/pr-management/src/pr_management/code_review/data.py +++ b/tools/pr-management/src/pr_management/code_review/data.py @@ -110,6 +110,7 @@ class PR: default_merge_method: str | None = None auto_merge_enabled: bool = False pending_not_required: tuple[str, ...] = () + ci_outdated_query: bool = False #: The last two commits with parents, verified signer and linked author logins (fixup checks). head_commits: tuple[dict[str, Any], ...] = () extra: dict[str, Any] = field(default_factory=dict) @@ -210,10 +211,17 @@ def from_node(node: dict[str, Any]) -> PR: commit_oid = str(latest_commit.get("oid") or "").strip().lower() # Golden rule 8: the CI status check rollup must strictly belong to the PR's head commit. - # If the commit OID is missing, empty, or does not match headRefOid (e.g. after a force-push - # or a stale GraphQL connection edge), fail closed by discarding the rollup. + # If commit OID matches headRefOid, the rollup is accepted. + # If commit OID mismatches headRefOid (e.g. lagging commit edge), fail closed by discarding. + # If statusCheckRollup exists but commit OID is missing, the installed vetted-ops queries are + # out of date (older vetted-ops without `oid` under `latest`). Fail closed to protect the gate, + # but flag `ci_outdated_query` so disposition can give an actionable upgrade instruction. + ci_outdated_query = False if head_sha and commit_oid and head_sha == commit_oid: rollup = latest_commit.get("statusCheckRollup") or {} + elif latest_commit.get("statusCheckRollup") and not commit_oid: + rollup = {} + ci_outdated_query = True else: rollup = {} users, teams = _requests(node) @@ -273,6 +281,7 @@ def from_node(node: dict[str, Any]) -> PR: rollup_state=rollup.get("state") if rollup else None, contexts=_contexts(rollup), pending_not_required=_pending_not_required(rollup) if rollup else (), + ci_outdated_query=ci_outdated_query, head_commits=_head_commits(node), node_id=node.get("id"), thread_details=tuple(threads), diff --git a/tools/pr-management/src/pr_management/code_review/disposition.py b/tools/pr-management/src/pr_management/code_review/disposition.py index 800924826..ad152bc0a 100644 --- a/tools/pr-management/src/pr_management/code_review/disposition.py +++ b/tools/pr-management/src/pr_management/code_review/disposition.py @@ -102,6 +102,7 @@ def pick( merge_method: str | None = None, pending_not_required: tuple[str, ...] | list[str] = (), viewer_known: bool = True, + ci_outdated_query: bool = False, ) -> dict[str, Any]: resolve_first = resolve_first or [] counts = Counter(str(f.get("severity") or "").lower() for f in findings) @@ -142,6 +143,9 @@ def pick( elif ci_state == "EXPECTED": gates.append("CI is waiting for workflow approval — the project's real CI has not run") ci_action = "approve the waiting workflow runs (pr-management-triage), then re-run disposition" + elif ci_outdated_query: + gates.append("CI rollup cannot be verified — vetted-ops is out of date (latest commit lacks OID)") + ci_action = "upgrade vetted-ops to update GraphQL queries, then re-run disposition" elif ci_state != "SUCCESS" and not held_by_auto_merge: gates.append(f"CI is {ci_state or 'not reported'}") if ci_state == "PENDING" and not real_ci: diff --git a/tools/pr-management/tests/code_review/test_data.py b/tools/pr-management/tests/code_review/test_data.py index 3f707cc1a..de014885b 100644 --- a/tools/pr-management/tests/code_review/test_data.py +++ b/tools/pr-management/tests/code_review/test_data.py @@ -79,8 +79,8 @@ def test_from_node_accepts_rollup_when_commit_oid_matches_head_ref_oid() -> None def test_from_node_discards_rollup_when_commit_oid_mismatches_head_ref_oid() -> None: - # Simulates Issue #2034: headRefOid is 3ac12395 after a force-push, - # but GraphQL returns a stale rollup on prior commit 4eb3567c. + # Guards against a lagging commit edge where latest: commits(last: 1) + # has not advanced to headRefOid (Direction 1 of #2034). node = { "number": 272, "headRefOid": "3ac12395", @@ -112,9 +112,12 @@ def test_from_node_discards_rollup_when_commit_oid_mismatches_head_ref_oid() -> assert pr.rollup_state is None assert pr.contexts == () assert pr.pending_not_required == () + assert pr.ci_outdated_query is False -def test_from_node_discards_rollup_when_commit_oid_missing() -> None: +def test_from_node_discards_rollup_and_flags_outdated_query_when_commit_oid_missing() -> None: + # Simulates query skew: an older vetted-ops installation that did not request `oid` + # under latest: commits(last: 1). Rollup is discarded for safety, but flagged as outdated. node = { "number": 1, "headRefOid": "3ac12395", @@ -134,6 +137,7 @@ def test_from_node_discards_rollup_when_commit_oid_missing() -> None: pr = data.from_node(node) assert pr.rollup_state is None assert pr.contexts == () + assert pr.ci_outdated_query is True def test_from_node_discards_rollup_when_head_ref_oid_missing() -> None: diff --git a/tools/pr-management/tests/code_review/test_decisions.py b/tools/pr-management/tests/code_review/test_decisions.py index 90f48671e..ed6a648d7 100644 --- a/tools/pr-management/tests/code_review/test_decisions.py +++ b/tools/pr-management/tests/code_review/test_decisions.py @@ -470,8 +470,8 @@ def test_markers_split_environments() -> None: assert result["evidence"] == {"uninstallable_in": ["py39"]} -def test_force_pushed_pr_with_stale_ci_rollup_fails_closed_to_comment() -> None: - # Issue #2034: PR head changed to 3ac12395, but GraphQL returned stale SUCCESS rollup on 4eb3567c. +def test_pr_with_lagging_commit_edge_fails_closed_to_comment() -> None: + # Direction 1 of #2034: latest commit node lags behind headRefOid (4eb3567c != 3ac12395). node = { "number": 272, "headRefOid": "3ac12395", @@ -504,3 +504,39 @@ def test_force_pushed_pr_with_stale_ci_rollup_fails_closed_to_comment() -> None: result = _pick_findings([], ci_state=pr.rollup_state) assert result["disposition"] == "COMMENT" assert "CI is not reported" in result["reason"] + + +def test_pr_with_outdated_vetted_ops_query_gates_approval_with_upgrade_action() -> None: + # Query skew: older vetted-ops without `oid` under `latest: commits(last: 1)`. + node = { + "number": 272, + "headRefOid": "3ac12395", + "latest": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "state": "SUCCESS", + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "tests", + "conclusion": "SUCCESS", + "status": "COMPLETED", + } + ] + }, + }, + } + } + ] + }, + } + pr = data.from_node(node) + assert pr.rollup_state is None + assert pr.ci_outdated_query is True + result = _pick_findings([], ci_state=pr.rollup_state, ci_outdated_query=pr.ci_outdated_query) + assert result["disposition"] == "COMMENT" + assert any("vetted-ops is out of date" in g for g in result["approval_gates"]) + assert result["ci_action"] == "upgrade vetted-ops to update GraphQL queries, then re-run disposition"