From e4f8a8ab5677559e72257894709296239871f7e8 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 24 Aug 2026 20:24:31 +0700 Subject: [PATCH 1/3] fix: escalate AI review risk from live PR labels --- .github/workflows/ai-review-gate.yml | 13 +++++- scripts/ai_review_gate.py | 37 ++++++++++++--- tests/test_ai_review_gate.py | 69 +++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ai-review-gate.yml b/.github/workflows/ai-review-gate.yml index 1a6555de..82aa7eb7 100644 --- a/.github/workflows/ai-review-gate.yml +++ b/.github/workflows/ai-review-gate.yml @@ -84,6 +84,14 @@ jobs: throw Error('pull request is not an open, ready default-branch change'); const files = await github.paginate(github.rest.pulls.listFiles, {owner, repo, pull_number: prNumber, per_page: 100}); + if (!Array.isArray(pr.data.labels)) throw Error('live PR labels unavailable'); + const normalizeLabels = (labels) => { + if (!Array.isArray(labels) || labels.some(label => + !label || typeof label.name !== 'string' || !label.name.trim())) + throw Error('live PR labels unavailable'); + return labels.map(label => label.name.trim().toLowerCase()).sort(); + }; + const labelNames = normalizeLabels(pr.data.labels); const query = `query($owner:String!,$repo:String!,$pr:Int!,$cursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$pr){reviewThreads(first:100,after:$cursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}`; let cursor = null, unresolved = 0; do { @@ -98,6 +106,7 @@ jobs: fs.writeFileSync('/tmp/gate.py', Buffer.from(source.data.content, 'base64')); const input = {repository: `${owner}/${repo}`, pr_number: prNumber, base_sha: pr.data.base.sha, head_sha: head, changed_paths: files.map(file => file.filename), + labels: labelNames, labels_complete: true, receipts: JSON.parse(process.env.RECEIPTS_JSON), unresolved_threads: unresolved, api_complete: true, pagination_complete: true}; const run = cp.spawnSync('python', ['/tmp/gate.py'], {input: JSON.stringify(input), encoding: 'utf8'}); @@ -105,7 +114,9 @@ jobs: const live = await github.rest.pulls.get({owner, repo, pull_number: prNumber}); const liveBranch = await github.rest.repos.getBranch({owner, repo, branch: defaultBranch}); if (live.data.head.sha !== head || live.data.base.sha !== pr.data.base.sha || - liveBranch.data.commit.sha !== protectedSha) throw Error('PR or protected-base drift'); + liveBranch.data.commit.sha !== protectedSha || + JSON.stringify(normalizeLabels(live.data.labels)) !== JSON.stringify(labelNames)) + throw Error('PR, labels, or protected-base drift'); conclusion = run.status === 0 && result.decision === 'PASS' ? 'success' : 'failure'; summary = JSON.stringify(result); } catch (error) { diff --git a/scripts/ai_review_gate.py b/scripts/ai_review_gate.py index ac70543e..ba230d39 100644 --- a/scripts/ai_review_gate.py +++ b/scripts/ai_review_gate.py @@ -58,7 +58,7 @@ def _identifier(value: Any) -> bool: return isinstance(value, str) and SAFE_ID.fullmatch(value) is not None -def _risk(paths: Any) -> str: +def _risk(paths: Any, labels: Any) -> str: if not isinstance(paths, list) or not paths or not all(isinstance(p, str) for p in paths): return "unknown" if any( @@ -68,10 +68,26 @@ def _risk(paths: Any) -> str: or path.startswith("scripts/ai_review_gate.py") for path in paths ): - return "governance" - if all(path in ROUTINE_FILES or path.startswith(ROUTINE_PREFIXES) for path in paths): - return "routine" - return "unknown" + path_risk = "governance" + elif all(path in ROUTINE_FILES or path.startswith(ROUTINE_PREFIXES) for path in paths): + path_risk = "routine" + else: + path_risk = "unknown" + + if not isinstance(labels, list) or not all(isinstance(label, str) for label in labels): + return "unknown" + label_risks = { + label.strip().casefold() + for label in labels + if label.strip().casefold() in HIGH_RISK + } + if path_risk in HIGH_RISK: + return path_risk + if len(label_risks) == 1: + return next(iter(label_risks)) + if label_risks: + return "unknown" + return path_risk def evaluate(data: Any, now: datetime | None = None) -> dict[str, Any]: @@ -79,13 +95,22 @@ def evaluate(data: Any, now: datetime | None = None) -> dict[str, Any]: if not isinstance(data, dict): return {"decision": "FAIL", "blockers": ["INPUT_INVALID"]} current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + labels = data.get("labels") + if data.get("labels_complete") is not True: + blockers.append("LABEL_STATE_INCOMPLETE") + if ( + not isinstance(labels, list) + or not all(isinstance(label, str) for label in labels) + or any(not label.strip() for label in labels) + ): + blockers.append("LABELS_INVALID") expected = { "schema_version": "1", "repository": REPOSITORY, "pr_number": data.get("pr_number"), "base_sha": data.get("base_sha"), "head_sha": data.get("head_sha"), - "risk_class": _risk(data.get("changed_paths")), + "risk_class": _risk(data.get("changed_paths"), labels), "policy_version": POLICY_VERSION, "integration_id": INTEGRATION_ID, } diff --git a/tests/test_ai_review_gate.py b/tests/test_ai_review_gate.py index a98eb0d0..1f53a315 100644 --- a/tests/test_ai_review_gate.py +++ b/tests/test_ai_review_gate.py @@ -37,13 +37,15 @@ def receipt(lane: str, *, risk: str = "routine", reviewer: str | None = None): } -def payload(paths: list[str] | None = None): +def payload(paths: list[str] | None = None, labels: list[str] | None = None): return { "repository": "electricsheephq/lcm-x", "pr_number": 350, "base_sha": BASE, "head_sha": HEAD, "changed_paths": paths or ["docs/operator-guide.md"], + "labels": [] if labels is None else labels, + "labels_complete": True, "receipts": [receipt("acceptance")], "unresolved_threads": 0, "api_complete": True, @@ -85,6 +87,67 @@ def test_unknown_risk_fails_closed_to_two_lanes(): assert "ADVERSARIAL_RECEIPT_COUNT_INVALID" in result["blockers"] +def test_high_risk_live_label_escalates_benchmark_and_tests_paths(): + data = payload(["bench/results.json", "tests/test_benchmark.py"], ["data-integrity"]) + data["receipts"] = [ + receipt("acceptance", risk="data-integrity"), + receipt("adversarial", risk="data-integrity"), + ] + + result = evaluate(data, NOW) + + assert result["decision"] == "PASS" + assert result["risk_class"] == "data-integrity" + + +def test_unlabeled_routine_benchmark_docs_input_requires_acceptance_only(): + data = payload(["bench/results.json", "docs/operator-guide.md"], []) + + result = evaluate(data, NOW) + + assert result["decision"] == "PASS" + assert result["risk_class"] == "routine" + + +def test_routine_labels_cannot_downgrade_governance_or_unknown_paths(): + for path, expected_risk in ( + (".github/workflows/ai-review-gate.yml", "governance"), + ("access_context/tools.py", "unknown"), + ): + data = payload([path], ["routine"]) + data["receipts"] = [ + receipt("acceptance", risk=expected_risk), + receipt("adversarial", risk=expected_risk), + ] + + result = evaluate(data, NOW) + + assert result["decision"] == "PASS" + assert result["risk_class"] == expected_risk + + +def test_missing_or_malformed_label_state_fails_closed(): + cases = ( + None, + {"labels": None}, + {"labels": "data-integrity"}, + {"labels": ["data-integrity", 1]}, + {"labels": ["data-integrity"], "labels_complete": False}, + ) + for changes in cases: + data = payload(["bench/results.json"]) + if changes is None: + data.pop("labels") + data.pop("labels_complete") + else: + data.update(changes) + + result = evaluate(data, NOW) + + assert result["decision"] == "FAIL" + assert any(blocker.startswith("LABEL") for blocker in result["blockers"]) + + def test_missing_or_extra_receipts_fail(): data = payload() data["receipts"] = [] @@ -172,6 +235,10 @@ def test_workflow_is_base_trusted_and_resets_each_head(): assert "core.setFailed(`Failed to reset PRs:" in workflow assert "context.ref !== `refs/heads/${defaultBranch}`" in workflow assert "ref: protectedSha" in workflow + assert "Array.isArray(pr.data.labels)" in workflow + assert "label.name.trim().toLowerCase()" in workflow + assert "labels_complete: true" in workflow + assert "labels: labelNames" in workflow assert "actions/checkout" not in workflow assert "pull_request.head" not in workflow assert "eval(" not in workflow From 80f569799c54f7898acf10cba7b9c594e5b4a6eb Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 24 Aug 2026 20:28:47 +0700 Subject: [PATCH 2/3] fix: paginate live PR labels in AI review gate --- .github/workflows/ai-review-gate.yml | 8 +++++--- tests/test_ai_review_gate.py | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ai-review-gate.yml b/.github/workflows/ai-review-gate.yml index 82aa7eb7..ef9e6756 100644 --- a/.github/workflows/ai-review-gate.yml +++ b/.github/workflows/ai-review-gate.yml @@ -84,14 +84,15 @@ jobs: throw Error('pull request is not an open, ready default-branch change'); const files = await github.paginate(github.rest.pulls.listFiles, {owner, repo, pull_number: prNumber, per_page: 100}); - if (!Array.isArray(pr.data.labels)) throw Error('live PR labels unavailable'); const normalizeLabels = (labels) => { if (!Array.isArray(labels) || labels.some(label => !label || typeof label.name !== 'string' || !label.name.trim())) throw Error('live PR labels unavailable'); return labels.map(label => label.name.trim().toLowerCase()).sort(); }; - const labelNames = normalizeLabels(pr.data.labels); + const loadLiveLabels = () => github.paginate(github.rest.issues.listLabelsOnIssue, + {owner, repo, issue_number: prNumber, per_page: 100}); + const labelNames = normalizeLabels(await loadLiveLabels()); const query = `query($owner:String!,$repo:String!,$pr:Int!,$cursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$pr){reviewThreads(first:100,after:$cursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}`; let cursor = null, unresolved = 0; do { @@ -113,9 +114,10 @@ jobs: const result = JSON.parse(run.stdout || '{}'); const live = await github.rest.pulls.get({owner, repo, pull_number: prNumber}); const liveBranch = await github.rest.repos.getBranch({owner, repo, branch: defaultBranch}); + const liveLabelNames = normalizeLabels(await loadLiveLabels()); if (live.data.head.sha !== head || live.data.base.sha !== pr.data.base.sha || liveBranch.data.commit.sha !== protectedSha || - JSON.stringify(normalizeLabels(live.data.labels)) !== JSON.stringify(labelNames)) + JSON.stringify(liveLabelNames) !== JSON.stringify(labelNames)) throw Error('PR, labels, or protected-base drift'); conclusion = run.status === 0 && result.decision === 'PASS' ? 'success' : 'failure'; summary = JSON.stringify(result); diff --git a/tests/test_ai_review_gate.py b/tests/test_ai_review_gate.py index 1f53a315..be3a2980 100644 --- a/tests/test_ai_review_gate.py +++ b/tests/test_ai_review_gate.py @@ -235,10 +235,13 @@ def test_workflow_is_base_trusted_and_resets_each_head(): assert "core.setFailed(`Failed to reset PRs:" in workflow assert "context.ref !== `refs/heads/${defaultBranch}`" in workflow assert "ref: protectedSha" in workflow - assert "Array.isArray(pr.data.labels)" in workflow + assert "const loadLiveLabels = () => github.paginate(github.rest.issues.listLabelsOnIssue" in workflow + assert "issue_number: prNumber" in workflow + assert workflow.count("await loadLiveLabels()") == 2 assert "label.name.trim().toLowerCase()" in workflow assert "labels_complete: true" in workflow assert "labels: labelNames" in workflow + assert "pr.data.labels" not in workflow assert "actions/checkout" not in workflow assert "pull_request.head" not in workflow assert "eval(" not in workflow From 02108a055cae4c37e140cae23dc7cba2070b1863 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 24 Aug 2026 20:34:20 +0700 Subject: [PATCH 3/3] fix: reset AI review on PR label changes --- .github/workflows/ai-review-gate.yml | 2 +- tests/test_ai_review_gate.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ai-review-gate.yml b/.github/workflows/ai-review-gate.yml index ef9e6756..f9f57e8c 100644 --- a/.github/workflows/ai-review-gate.yml +++ b/.github/workflows/ai-review-gate.yml @@ -5,7 +5,7 @@ on: branches: [main] pull_request_target: branches: [main] - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] workflow_dispatch: inputs: pr_number: diff --git a/tests/test_ai_review_gate.py b/tests/test_ai_review_gate.py index be3a2980..9b6da07d 100644 --- a/tests/test_ai_review_gate.py +++ b/tests/test_ai_review_gate.py @@ -245,3 +245,14 @@ def test_workflow_is_base_trusted_and_resets_each_head(): assert "actions/checkout" not in workflow assert "pull_request.head" not in workflow assert "eval(" not in workflow + + +def test_workflow_resets_on_label_lifecycle_events(): + workflow = ( + REPO_ROOT / ".github" / "workflows" / "ai-review-gate.yml" + ).read_text(encoding="utf-8") + + assert "types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]" in workflow + assert "await emit(prNumber, base, head, 'failure', context.eventName === 'workflow_dispatch'" in workflow + assert "'Exact-head review receipts are missing.'" in workflow + assert "if (context.eventName !== 'workflow_dispatch') return;" in workflow