From 7f46901b224723f69053fdd7c94a7626db864332 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 17:53:27 +0900 Subject: [PATCH 1/9] feat(ci): add secondary review approval gate --- .github/workflows/secondary-review.yml | 97 +++++++++ scripts/ci/secondary_review_gate.py | 287 +++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 .github/workflows/secondary-review.yml create mode 100644 scripts/ci/secondary_review_gate.py diff --git a/.github/workflows/secondary-review.yml b/.github/workflows/secondary-review.yml new file mode 100644 index 000000000..0ac98545c --- /dev/null +++ b/.github/workflows/secondary-review.yml @@ -0,0 +1,97 @@ +name: Required Secondary Review + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + workflow_run: + workflows: ["Required OpenCode Review", "Strix Security Scan"] + types: [completed] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to review + required: true + type: string + target_repository: + description: Repository that owns the pull request, in owner/name form + required: false + default: "" + type: string + canonical_ref: + description: Ref of ContextualWisdomLab/.github to use for trusted review scripts + required: false + default: main + type: string + +concurrency: + group: >- + secondary-review-${{ github.event_name }}-${{ + github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }}-${{ + github.event_name == 'pull_request_target' && format('pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || + github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number) || + github.event.inputs.pr_number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + checks: read + +jobs: + approve-after-primary-review: + name: approve-after-primary-review + runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' + || github.event_name == 'workflow_run' + || ( + github.event_name == 'pull_request_target' + && github.event.pull_request.head.repo.full_name == github.repository + ) + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.inputs.pr_number || '' }} + REVIEW_TOKEN_SOURCE: ${{ secrets.SECONDARY_REVIEW_TOKEN != '' && 'SECONDARY_REVIEW_TOKEN' || secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || '' }} + steps: + - name: Resolve trusted secondary review source ref + id: trusted_source + env: + INPUT_CANONICAL_REF: ${{ github.event.inputs.canonical_ref || '' }} + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + set -euo pipefail + trusted_ref="${INPUT_CANONICAL_REF:-main}" + case "$WORKFLOW_REF" in + ContextualWisdomLab/.github/.github/workflows/secondary-review.yml@*) + trusted_ref="${WORKFLOW_REF##*@}" + ;; + esac + printf 'ref=%s\n' "$trusted_ref" >>"$GITHUB_OUTPUT" + + - name: Checkout trusted secondary review gate + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ContextualWisdomLab/.github + ref: ${{ steps.trusted_source.outputs.ref }} + fetch-depth: 1 + persist-credentials: false + + - name: Submit secondary approval when primary review is clean + env: + GH_TOKEN: ${{ secrets.SECONDARY_REVIEW_TOKEN || secrets.PR_REVIEW_MERGE_TOKEN }} + SECONDARY_REVIEW_TOKEN_SOURCE: ${{ env.REVIEW_TOKEN_SOURCE }} + SECONDARY_REVIEW_AUTHOR_HINT: secondary-review-bot + run: | + set -euo pipefail + if [ -z "${PR_NUMBER:-}" ]; then + echo "No pull request number was available for this event; skipping." + exit 0 + fi + if [ -z "${GH_TOKEN:-}" ]; then + echo "::notice::SECONDARY_REVIEW_TOKEN is not configured and PR_REVIEW_MERGE_TOKEN is unavailable; secondary approval skipped." + exit 0 + fi + python3 scripts/ci/secondary_review_gate.py \ + --repo "$TARGET_REPOSITORY" \ + --pr-number "$PR_NUMBER" diff --git a/scripts/ci/secondary_review_gate.py b/scripts/ci/secondary_review_gate.py new file mode 100644 index 000000000..db980ff17 --- /dev/null +++ b/scripts/ci/secondary_review_gate.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Submit a second, independent PR approval after primary review gates pass.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from collections.abc import Sequence +from typing import Any + + +PRIMARY_REVIEW_AUTHORS = { + "opencode-agent[bot]", + "opencode-agent", + "github-actions[bot]", +} +PRIMARY_REVIEW_MARKERS = ( + "OpenCode reviewed the current-head bounded evidence and found no blocking issues.", + "Result: APPROVE", + "opencode-review-control-v1", +) +IGNORED_RUNNING_CHECKS = { + "approve-after-primary-review", + "Required Secondary Review", +} +FAILED_CONCLUSIONS = {"FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"} +RUNNING_STATES = {"QUEUED", "IN_PROGRESS", "PENDING", "REQUESTED", "WAITING", "EXPECTED"} + + +def run(args: Sequence[str], *, stdin: str | None = None) -> str: + if isinstance(args, str): + raise TypeError("run() requires argv, not a shell command string") + completed = subprocess.run( + list(args), + input=stdin, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError( + f"Command failed ({completed.returncode}): {' '.join(args)}\n{completed.stderr.strip()}" + ) + return completed.stdout + + +def gh_json(args: Sequence[str]) -> Any: + return json.loads(run(["gh", "api", *args])) + + +def split_repo(repo: str) -> tuple[str, str]: + owner, name = repo.split("/", 1) + if not owner or not name: + raise ValueError(f"repo must be owner/name, got {repo!r}") + return owner, name + + +def graphql(query: str, **fields: str | int) -> dict[str, Any]: + args = ["gh", "api", "graphql", "-F", "query=@-"] + for key, value in fields.items(): + args.extend(["-F" if isinstance(value, int) else "-f", f"{key}={value}"]) + return json.loads(run(args, stdin=query)) + + +PR_QUERY = """\ +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + number + title + isDraft + headRefOid + reviewDecision + reviewThreads(first: 100) { + nodes { isResolved isOutdated } + } + reviews(last: 100) { + nodes { + state + body + author { login } + commit { oid } + } + } + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + status + conclusion + checkSuite { + workflowRun { + workflow { name } + } + } + } + ... on StatusContext { + context + state + } + } + } + } + } + } +} +""" + + +def fetch_pr(repo: str, number: int) -> dict[str, Any]: + owner, name = split_repo(repo) + data = graphql(PR_QUERY, owner=owner, name=name, number=number) + pr = data.get("data", {}).get("repository", {}).get("pullRequest") + if not pr: + raise RuntimeError(f"PR #{number} was not found in {repo}") + return pr + + +def review_author(review: dict[str, Any]) -> str: + return ((review.get("author") or {}).get("login") or "").strip() + + +def review_commit(review: dict[str, Any]) -> str: + return ((review.get("commit") or {}).get("oid") or "").strip() + + +def current_primary_approval(pr: dict[str, Any]) -> dict[str, Any] | None: + head_sha = str(pr.get("headRefOid") or "") + reviews = (((pr.get("reviews") or {}).get("nodes")) or []) + for review in reversed(reviews): + if review_commit(review) != head_sha: + continue + if str(review.get("state") or "").upper() != "APPROVED": + continue + body = str(review.get("body") or "") + author = review_author(review) + if author in PRIMARY_REVIEW_AUTHORS and any(marker in body for marker in PRIMARY_REVIEW_MARKERS): + return review + return None + + +def has_current_changes_requested(pr: dict[str, Any]) -> bool: + head_sha = str(pr.get("headRefOid") or "") + reviews = (((pr.get("reviews") or {}).get("nodes")) or []) + for review in reversed(reviews): + if review_commit(review) == head_sha and str(review.get("state") or "").upper() == "CHANGES_REQUESTED": + return True + return False + + +def has_unresolved_threads(pr: dict[str, Any]) -> bool: + threads = (((pr.get("reviewThreads") or {}).get("nodes")) or []) + return any(not thread.get("isResolved") and not thread.get("isOutdated") for thread in threads) + + +def check_label(node: dict[str, Any]) -> str: + if node.get("__typename") == "StatusContext": + return str(node.get("context") or "") + workflow = ((((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {}).get("name") or "") + name = str(node.get("name") or "") + return f"{workflow} / {name}" if workflow else name + + +def blocking_checks(pr: dict[str, Any]) -> list[str]: + contexts = ((((pr.get("statusCheckRollup") or {}).get("contexts") or {}).get("nodes")) or []) + blockers: list[str] = [] + for node in contexts: + label = check_label(node) + if label in IGNORED_RUNNING_CHECKS or str(node.get("name") or "") in IGNORED_RUNNING_CHECKS: + continue + if node.get("__typename") == "StatusContext": + state = str(node.get("state") or "").upper() + if state not in {"SUCCESS", "NEUTRAL"}: + blockers.append(f"{label}: {state}") + continue + status = str(node.get("status") or "").upper() + conclusion = str(node.get("conclusion") or "").upper() + if conclusion in FAILED_CONCLUSIONS: + blockers.append(f"{label}: {conclusion}") + elif status in RUNNING_STATES and conclusion not in {"SUCCESS", "NEUTRAL", "SKIPPED"}: + blockers.append(f"{label}: {status}") + return blockers + + +def existing_secondary_review(pr: dict[str, Any], actor: str) -> bool: + head_sha = str(pr.get("headRefOid") or "") + marker = "", + ] + ) + payload = { + "commit_id": head_sha, + "event": "APPROVE", + "body": body, + } + run( + ["gh", "api", "-X", "POST", f"repos/{repo}/pulls/{number}/reviews", "--input", "-"], + stdin=json.dumps(payload), + ) + print(f"Secondary approval submitted for {repo}#{number} at {head_sha}.") + + +def inspect_and_approve(repo: str, number: int) -> int: + pr = fetch_pr(repo, number) + actor = current_actor() + if pr.get("isDraft"): + print("PR is draft; secondary approval skipped.") + return 0 + if existing_secondary_review(pr, actor): + print("Current head already has a secondary approval; nothing to do.") + return 0 + if not current_primary_approval(pr): + print("Current head does not have a primary OpenCode approval; secondary approval skipped.") + return 0 + if has_current_changes_requested(pr): + print("Current head has requested changes; secondary approval skipped.") + return 0 + if has_unresolved_threads(pr): + print("PR has unresolved review threads; secondary approval skipped.") + return 0 + blockers = blocking_checks(pr) + if blockers: + print("Blocking checks remain; secondary approval skipped:") + for blocker in blockers: + print(f"- {blocker}") + return 0 + approve(repo, number, pr, actor) + return 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", required=True) + parser.add_argument("--pr-number", required=True, type=int) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.pr_number <= 0: + raise SystemExit("--pr-number must be positive") + return inspect_and_approve(args.repo, args.pr_number) + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv[1:])) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) from exc From 0c738d4394668633001e0bb9cfe0416cbbbf35eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 17:54:40 +0900 Subject: [PATCH 2/9] fix(ci): use OIDC for secondary reviewer token --- .github/workflows/secondary-review.yml | 74 ++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/.github/workflows/secondary-review.yml b/.github/workflows/secondary-review.yml index 0ac98545c..83a1670b2 100644 --- a/.github/workflows/secondary-review.yml +++ b/.github/workflows/secondary-review.yml @@ -36,6 +36,7 @@ permissions: contents: read pull-requests: read checks: read + id-token: write jobs: approve-after-primary-review: @@ -52,7 +53,6 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.inputs.pr_number || '' }} - REVIEW_TOKEN_SOURCE: ${{ secrets.SECONDARY_REVIEW_TOKEN != '' && 'SECONDARY_REVIEW_TOKEN' || secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || '' }} steps: - name: Resolve trusted secondary review source ref id: trusted_source @@ -77,10 +77,76 @@ jobs: fetch-depth: 1 persist-credentials: false + - name: Exchange secondary review app token + id: secondary_app_token + env: + OIDC_AUDIENCE: ${{ vars.SECONDARY_REVIEW_OIDC_AUDIENCE || 'secondary-review-github-action' }} + TOKEN_EXCHANGE_URL: ${{ vars.SECONDARY_REVIEW_TOKEN_EXCHANGE_URL || 'https://api.opencode.ai/exchange_secondary_review_github_app_token' }} + run: | + set -euo pipefail + + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "Secondary review app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + + if ! oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "Secondary review app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "Secondary review app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + + if ! token_response="$( + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${TOKEN_EXCHANGE_URL}" + )"; then + echo "Secondary review app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "Secondary review app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + - name: Submit secondary approval when primary review is clean env: - GH_TOKEN: ${{ secrets.SECONDARY_REVIEW_TOKEN || secrets.PR_REVIEW_MERGE_TOKEN }} - SECONDARY_REVIEW_TOKEN_SOURCE: ${{ env.REVIEW_TOKEN_SOURCE }} + GH_TOKEN: ${{ steps.secondary_app_token.outputs.token }} + SECONDARY_REVIEW_TOKEN_SOURCE: secondary-review-app-oidc SECONDARY_REVIEW_AUTHOR_HINT: secondary-review-bot run: | set -euo pipefail @@ -89,7 +155,7 @@ jobs: exit 0 fi if [ -z "${GH_TOKEN:-}" ]; then - echo "::notice::SECONDARY_REVIEW_TOKEN is not configured and PR_REVIEW_MERGE_TOKEN is unavailable; secondary approval skipped." + echo "::notice::Secondary review app token is unavailable; secondary approval skipped." exit 0 fi python3 scripts/ci/secondary_review_gate.py \ From 61345027bbfaaf67380c6bcfb632b3bdcd33c966 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 17:55:53 +0900 Subject: [PATCH 3/9] fix(ci): require distinct secondary review app --- .github/workflows/secondary-review.yml | 8 +++++++- scripts/ci/secondary_review_gate.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/secondary-review.yml b/.github/workflows/secondary-review.yml index 83a1670b2..7cc293ca1 100644 --- a/.github/workflows/secondary-review.yml +++ b/.github/workflows/secondary-review.yml @@ -81,7 +81,7 @@ jobs: id: secondary_app_token env: OIDC_AUDIENCE: ${{ vars.SECONDARY_REVIEW_OIDC_AUDIENCE || 'secondary-review-github-action' }} - TOKEN_EXCHANGE_URL: ${{ vars.SECONDARY_REVIEW_TOKEN_EXCHANGE_URL || 'https://api.opencode.ai/exchange_secondary_review_github_app_token' }} + TOKEN_EXCHANGE_URL: ${{ vars.SECONDARY_REVIEW_TOKEN_EXCHANGE_URL || '' }} run: | set -euo pipefail @@ -89,6 +89,12 @@ jobs: echo "available=false" >>"$GITHUB_OUTPUT" } + if [ -z "${TOKEN_EXCHANGE_URL:-}" ]; then + echo "Secondary review app token exchange unavailable: SECONDARY_REVIEW_TOKEN_EXCHANGE_URL is not configured." + mark_unavailable + exit 0 + fi + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then echo "Secondary review app token exchange unavailable: OIDC request environment is missing." mark_unavailable diff --git a/scripts/ci/secondary_review_gate.py b/scripts/ci/secondary_review_gate.py index db980ff17..5b2758982 100644 --- a/scripts/ci/secondary_review_gate.py +++ b/scripts/ci/secondary_review_gate.py @@ -240,6 +240,12 @@ def approve(repo: str, number: int, pr: dict[str, Any], actor: str) -> None: def inspect_and_approve(repo: str, number: int) -> int: pr = fetch_pr(repo, number) actor = current_actor() + if actor in PRIMARY_REVIEW_AUTHORS: + print( + f"Current token actor {actor!r} is already a primary review actor; " + "secondary approval skipped so GitHub receives an independent reviewer." + ) + return 0 if pr.get("isDraft"): print("PR is draft; secondary approval skipped.") return 0 From 572a476105578770bba8b4f154a05327b4c91693 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 17:56:30 +0900 Subject: [PATCH 4/9] chore(ci): identify secondary review as deterministic gate --- .github/workflows/secondary-review.yml | 18 +++++++++--------- scripts/ci/secondary_review_gate.py | 6 ++++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/secondary-review.yml b/.github/workflows/secondary-review.yml index 7cc293ca1..73e865e73 100644 --- a/.github/workflows/secondary-review.yml +++ b/.github/workflows/secondary-review.yml @@ -1,4 +1,4 @@ -name: Required Secondary Review +name: Required Deterministic Secondary Review on: pull_request_target: @@ -80,7 +80,7 @@ jobs: - name: Exchange secondary review app token id: secondary_app_token env: - OIDC_AUDIENCE: ${{ vars.SECONDARY_REVIEW_OIDC_AUDIENCE || 'secondary-review-github-action' }} + OIDC_AUDIENCE: ${{ vars.SECONDARY_REVIEW_OIDC_AUDIENCE || 'cwl-deterministic-secondary-review' }} TOKEN_EXCHANGE_URL: ${{ vars.SECONDARY_REVIEW_TOKEN_EXCHANGE_URL || '' }} run: | set -euo pipefail @@ -90,13 +90,13 @@ jobs: } if [ -z "${TOKEN_EXCHANGE_URL:-}" ]; then - echo "Secondary review app token exchange unavailable: SECONDARY_REVIEW_TOKEN_EXCHANGE_URL is not configured." + echo "Deterministic secondary review app token exchange unavailable: SECONDARY_REVIEW_TOKEN_EXCHANGE_URL is not configured." mark_unavailable exit 0 fi if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then - echo "Secondary review app token exchange unavailable: OIDC request environment is missing." + echo "Deterministic secondary review app token exchange unavailable: OIDC request environment is missing." mark_unavailable exit 0 fi @@ -113,14 +113,14 @@ jobs: -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ "${request_url}${separator}audience=${OIDC_AUDIENCE}" )"; then - echo "Secondary review app token exchange unavailable: OIDC token request did not complete." + echo "Deterministic secondary review app token exchange unavailable: OIDC token request did not complete." mark_unavailable exit 0 fi oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" if [ -z "$oidc_token" ]; then - echo "Secondary review app token exchange unavailable: OIDC token response was empty." + echo "Deterministic secondary review app token exchange unavailable: OIDC token response was empty." mark_unavailable exit 0 fi @@ -131,14 +131,14 @@ jobs: -H "Authorization: Bearer ${oidc_token}" \ "${TOKEN_EXCHANGE_URL}" )"; then - echo "Secondary review app token exchange unavailable: app token request did not complete." + echo "Deterministic secondary review app token exchange unavailable: app token request did not complete." mark_unavailable exit 0 fi app_token="$(jq -r '.token // empty' <<<"$token_response")" if [ -z "$app_token" ]; then - echo "Secondary review app token exchange unavailable: app token response was empty." + echo "Deterministic secondary review app token exchange unavailable: app token response was empty." mark_unavailable exit 0 fi @@ -161,7 +161,7 @@ jobs: exit 0 fi if [ -z "${GH_TOKEN:-}" ]; then - echo "::notice::Secondary review app token is unavailable; secondary approval skipped." + echo "::notice::Deterministic secondary review app token is unavailable; secondary approval skipped." exit 0 fi python3 scripts/ci/secondary_review_gate.py \ diff --git a/scripts/ci/secondary_review_gate.py b/scripts/ci/secondary_review_gate.py index 5b2758982..3e67b89d2 100644 --- a/scripts/ci/secondary_review_gate.py +++ b/scripts/ci/secondary_review_gate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Submit a second, independent PR approval after primary review gates pass.""" +"""Submit a deterministic, non-OpenCode PR approval after primary gates pass.""" from __future__ import annotations @@ -215,7 +215,9 @@ def approve(repo: str, number: int, pr: dict[str, Any], actor: str) -> None: [ "## Secondary review gate", "", - "The secondary review gate found a current-head primary OpenCode approval, no current-head change requests, no unresolved review threads, and no blocking GitHub checks.", + "The deterministic secondary review program found a current-head primary OpenCode approval, no current-head change requests, no unresolved review threads, and no blocking GitHub checks.", + "", + "This approval is generated by the deterministic secondary gate, not by OpenCode model review.", "", "- Result: APPROVE", f"- Head SHA: `{head_sha}`", From 152cddff42331094431e0eb25ef7112bb90c39aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 18:06:02 +0900 Subject: [PATCH 5/9] fix(ci): brand secondary review gate as Noema --- ...{secondary-review.yml => noema-review.yml} | 42 +++++++++---------- ...ry_review_gate.py => noema_review_gate.py} | 32 +++++++------- 2 files changed, 37 insertions(+), 37 deletions(-) rename .github/workflows/{secondary-review.yml => noema-review.yml} (73%) rename scripts/ci/{secondary_review_gate.py => noema_review_gate.py} (87%) diff --git a/.github/workflows/secondary-review.yml b/.github/workflows/noema-review.yml similarity index 73% rename from .github/workflows/secondary-review.yml rename to .github/workflows/noema-review.yml index 73e865e73..a7ba90203 100644 --- a/.github/workflows/secondary-review.yml +++ b/.github/workflows/noema-review.yml @@ -1,4 +1,4 @@ -name: Required Deterministic Secondary Review +name: Required Noema Review on: pull_request_target: @@ -25,7 +25,7 @@ on: concurrency: group: >- - secondary-review-${{ github.event_name }}-${{ + noema-review-${{ github.event_name }}-${{ github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }}-${{ github.event_name == 'pull_request_target' && format('pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number) || @@ -54,7 +54,7 @@ jobs: TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.inputs.pr_number || '' }} steps: - - name: Resolve trusted secondary review source ref + - name: Resolve trusted Noema review source ref id: trusted_source env: INPUT_CANONICAL_REF: ${{ github.event.inputs.canonical_ref || '' }} @@ -63,13 +63,13 @@ jobs: set -euo pipefail trusted_ref="${INPUT_CANONICAL_REF:-main}" case "$WORKFLOW_REF" in - ContextualWisdomLab/.github/.github/workflows/secondary-review.yml@*) + ContextualWisdomLab/.github/.github/workflows/noema-review.yml@*) trusted_ref="${WORKFLOW_REF##*@}" ;; esac printf 'ref=%s\n' "$trusted_ref" >>"$GITHUB_OUTPUT" - - name: Checkout trusted secondary review gate + - name: Checkout trusted Noema review gate uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: ContextualWisdomLab/.github @@ -77,11 +77,11 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Exchange secondary review app token - id: secondary_app_token + - name: Exchange Noema app token + id: noema_app_token env: - OIDC_AUDIENCE: ${{ vars.SECONDARY_REVIEW_OIDC_AUDIENCE || 'cwl-deterministic-secondary-review' }} - TOKEN_EXCHANGE_URL: ${{ vars.SECONDARY_REVIEW_TOKEN_EXCHANGE_URL || '' }} + OIDC_AUDIENCE: ${{ vars.NOEMA_OIDC_AUDIENCE || 'cwl-noema-review' }} + TOKEN_EXCHANGE_URL: ${{ vars.NOEMA_TOKEN_EXCHANGE_URL || '' }} run: | set -euo pipefail @@ -90,13 +90,13 @@ jobs: } if [ -z "${TOKEN_EXCHANGE_URL:-}" ]; then - echo "Deterministic secondary review app token exchange unavailable: SECONDARY_REVIEW_TOKEN_EXCHANGE_URL is not configured." + echo "Noema app token exchange unavailable: NOEMA_TOKEN_EXCHANGE_URL is not configured." mark_unavailable exit 0 fi if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then - echo "Deterministic secondary review app token exchange unavailable: OIDC request environment is missing." + echo "Noema app token exchange unavailable: OIDC request environment is missing." mark_unavailable exit 0 fi @@ -113,14 +113,14 @@ jobs: -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ "${request_url}${separator}audience=${OIDC_AUDIENCE}" )"; then - echo "Deterministic secondary review app token exchange unavailable: OIDC token request did not complete." + echo "Noema app token exchange unavailable: OIDC token request did not complete." mark_unavailable exit 0 fi oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" if [ -z "$oidc_token" ]; then - echo "Deterministic secondary review app token exchange unavailable: OIDC token response was empty." + echo "Noema app token exchange unavailable: OIDC token response was empty." mark_unavailable exit 0 fi @@ -131,14 +131,14 @@ jobs: -H "Authorization: Bearer ${oidc_token}" \ "${TOKEN_EXCHANGE_URL}" )"; then - echo "Deterministic secondary review app token exchange unavailable: app token request did not complete." + echo "Noema app token exchange unavailable: app token request did not complete." mark_unavailable exit 0 fi app_token="$(jq -r '.token // empty' <<<"$token_response")" if [ -z "$app_token" ]; then - echo "Deterministic secondary review app token exchange unavailable: app token response was empty." + echo "Noema app token exchange unavailable: app token response was empty." mark_unavailable exit 0 fi @@ -149,11 +149,11 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" - - name: Submit secondary approval when primary review is clean + - name: Submit Noema approval when primary review is clean env: - GH_TOKEN: ${{ steps.secondary_app_token.outputs.token }} - SECONDARY_REVIEW_TOKEN_SOURCE: secondary-review-app-oidc - SECONDARY_REVIEW_AUTHOR_HINT: secondary-review-bot + GH_TOKEN: ${{ steps.noema_app_token.outputs.token }} + SECONDARY_REVIEW_TOKEN_SOURCE: noema-review-app-oidc + SECONDARY_REVIEW_AUTHOR_HINT: noema-review-bot run: | set -euo pipefail if [ -z "${PR_NUMBER:-}" ]; then @@ -161,9 +161,9 @@ jobs: exit 0 fi if [ -z "${GH_TOKEN:-}" ]; then - echo "::notice::Deterministic secondary review app token is unavailable; secondary approval skipped." + echo "::notice::Noema app token is unavailable; approval skipped." exit 0 fi - python3 scripts/ci/secondary_review_gate.py \ + python3 scripts/ci/noema_review_gate.py \ --repo "$TARGET_REPOSITORY" \ --pr-number "$PR_NUMBER" diff --git a/scripts/ci/secondary_review_gate.py b/scripts/ci/noema_review_gate.py similarity index 87% rename from scripts/ci/secondary_review_gate.py rename to scripts/ci/noema_review_gate.py index 3e67b89d2..d64d145c8 100644 --- a/scripts/ci/secondary_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Submit a deterministic, non-OpenCode PR approval after primary gates pass.""" +"""Submit a Noema, non-OpenCode PR approval after primary gates pass.""" from __future__ import annotations @@ -24,7 +24,7 @@ ) IGNORED_RUNNING_CHECKS = { "approve-after-primary-review", - "Required Secondary Review", + "Required Noema Review", } FAILED_CONCLUSIONS = {"FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"} RUNNING_STATES = {"QUEUED", "IN_PROGRESS", "PENDING", "REQUESTED", "WAITING", "EXPECTED"} @@ -190,7 +190,7 @@ def blocking_checks(pr: dict[str, Any]) -> list[str]: def existing_secondary_review(pr: dict[str, Any], actor: str) -> bool: head_sha = str(pr.get("headRefOid") or "") - marker = "", + f"", ] ) payload = { @@ -236,7 +236,7 @@ def approve(repo: str, number: int, pr: dict[str, Any], actor: str) -> None: ["gh", "api", "-X", "POST", f"repos/{repo}/pulls/{number}/reviews", "--input", "-"], stdin=json.dumps(payload), ) - print(f"Secondary approval submitted for {repo}#{number} at {head_sha}.") + print(f"Noema approval submitted for {repo}#{number} at {head_sha}.") def inspect_and_approve(repo: str, number: int) -> int: @@ -245,27 +245,27 @@ def inspect_and_approve(repo: str, number: int) -> int: if actor in PRIMARY_REVIEW_AUTHORS: print( f"Current token actor {actor!r} is already a primary review actor; " - "secondary approval skipped so GitHub receives an independent reviewer." + "Noema approval skipped so GitHub receives an independent reviewer." ) return 0 if pr.get("isDraft"): - print("PR is draft; secondary approval skipped.") + print("PR is draft; Noema approval skipped.") return 0 if existing_secondary_review(pr, actor): - print("Current head already has a secondary approval; nothing to do.") + print("Current head already has a Noema approval; nothing to do.") return 0 if not current_primary_approval(pr): - print("Current head does not have a primary OpenCode approval; secondary approval skipped.") + print("Current head does not have a primary OpenCode approval; Noema approval skipped.") return 0 if has_current_changes_requested(pr): - print("Current head has requested changes; secondary approval skipped.") + print("Current head has requested changes; Noema approval skipped.") return 0 if has_unresolved_threads(pr): - print("PR has unresolved review threads; secondary approval skipped.") + print("PR has unresolved review threads; Noema approval skipped.") return 0 blockers = blocking_checks(pr) if blockers: - print("Blocking checks remain; secondary approval skipped:") + print("Blocking checks remain; Noema approval skipped:") for blocker in blockers: print(f"- {blocker}") return 0 From e436efc0be7504f4e2127b4f1c7d64dadcf11fbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 18:09:22 +0900 Subject: [PATCH 6/9] feat(ci): run Noema LLM review before approval --- .github/workflows/noema-review.yml | 16 +-- scripts/ci/noema_review_gate.py | 154 ++++++++++++++++++++++++----- 2 files changed, 136 insertions(+), 34 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index a7ba90203..ff885e317 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -39,8 +39,8 @@ permissions: id-token: write jobs: - approve-after-primary-review: - name: approve-after-primary-review + noema-review: + name: noema-review runs-on: ubuntu-latest if: >- github.event_name == 'workflow_dispatch' @@ -149,11 +149,13 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" - - name: Submit Noema approval when primary review is clean + - name: Run Noema LLM review and submit verdict env: GH_TOKEN: ${{ steps.noema_app_token.outputs.token }} - SECONDARY_REVIEW_TOKEN_SOURCE: noema-review-app-oidc - SECONDARY_REVIEW_AUTHOR_HINT: noema-review-bot + NOEMA_REVIEW_TOKEN_SOURCE: noema-review-app-oidc + NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL || '' }} + NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL || '' }} + NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY || '' }} run: | set -euo pipefail if [ -z "${PR_NUMBER:-}" ]; then @@ -161,9 +163,9 @@ jobs: exit 0 fi if [ -z "${GH_TOKEN:-}" ]; then - echo "::notice::Noema app token is unavailable; approval skipped." + echo "::notice::Noema app token is unavailable; review skipped." exit 0 fi python3 scripts/ci/noema_review_gate.py \ --repo "$TARGET_REPOSITORY" \ - --pr-number "$PR_NUMBER" + --pr-number "$PR_NUMBER" \ No newline at end of file diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index d64d145c8..3b232a41b 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Submit a Noema, non-OpenCode PR approval after primary gates pass.""" +"""Run Noema LLM review and submit a non-OpenCode PR review verdict.""" from __future__ import annotations @@ -8,6 +8,7 @@ import os import subprocess import sys +import urllib.request from collections.abc import Sequence from typing import Any @@ -24,10 +25,12 @@ ) IGNORED_RUNNING_CHECKS = { "approve-after-primary-review", + "noema-review", "Required Noema Review", } FAILED_CONCLUSIONS = {"FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"} RUNNING_STATES = {"QUEUED", "IN_PROGRESS", "PENDING", "REQUESTED", "WAITING", "EXPECTED"} +MAX_DIFF_CHARS = 60000 def run(args: Sequence[str], *, stdin: str | None = None) -> str: @@ -48,10 +51,6 @@ def run(args: Sequence[str], *, stdin: str | None = None) -> str: return completed.stdout -def gh_json(args: Sequence[str]) -> Any: - return json.loads(run(["gh", "api", *args])) - - def split_repo(repo: str) -> tuple[str, str]: owner, name = repo.split("/", 1) if not owner or not name: @@ -72,6 +71,7 @@ def graphql(query: str, **fields: str | int) -> dict[str, Any]: pullRequest(number: $number) { number title + body isDraft headRefOid reviewDecision @@ -188,13 +188,13 @@ def blocking_checks(pr: dict[str, Any]) -> list[str]: return blockers -def existing_secondary_review(pr: dict[str, Any], actor: str) -> bool: +def existing_noema_review(pr: dict[str, Any], actor: str) -> bool: head_sha = str(pr.get("headRefOid") or "") marker = "", + f"", ] ) payload = { "commit_id": head_sha, - "event": "APPROVE", + "event": event, "body": body, } run( ["gh", "api", "-X", "POST", f"repos/{repo}/pulls/{number}/reviews", "--input", "-"], stdin=json.dumps(payload), ) - print(f"Noema approval submitted for {repo}#{number} at {head_sha}.") + print(f"Noema {event} review submitted for {repo}#{number} at {head_sha}.") -def inspect_and_approve(repo: str, number: int) -> int: +def inspect_and_review(repo: str, number: int) -> int: pr = fetch_pr(repo, number) actor = current_actor() if actor in PRIMARY_REVIEW_AUTHORS: print( f"Current token actor {actor!r} is already a primary review actor; " - "Noema approval skipped so GitHub receives an independent reviewer." + "Noema review skipped so GitHub receives an independent reviewer." ) return 0 if pr.get("isDraft"): - print("PR is draft; Noema approval skipped.") + print("PR is draft; Noema review skipped.") return 0 - if existing_secondary_review(pr, actor): - print("Current head already has a Noema approval; nothing to do.") + if existing_noema_review(pr, actor): + print("Current head already has a Noema review; nothing to do.") return 0 if not current_primary_approval(pr): - print("Current head does not have a primary OpenCode approval; Noema approval skipped.") + print("Current head does not have a primary OpenCode approval; Noema review skipped.") return 0 if has_current_changes_requested(pr): - print("Current head has requested changes; Noema approval skipped.") + print("Current head has requested changes; Noema review skipped.") return 0 if has_unresolved_threads(pr): - print("PR has unresolved review threads; Noema approval skipped.") + print("PR has unresolved review threads; Noema review skipped.") return 0 blockers = blocking_checks(pr) if blockers: - print("Blocking checks remain; Noema approval skipped:") + print("Blocking checks remain; Noema review skipped:") for blocker in blockers: print(f"- {blocker}") return 0 - approve(repo, number, pr, actor) + diff, truncated = fetch_diff(repo, number) + verdict = call_llm(repo, number, pr, diff, truncated) + if verdict is None: + return 0 + submit_review(repo, number, pr, actor, verdict) return 0 @@ -284,7 +384,7 @@ def main(argv: list[str]) -> int: args = parse_args(argv) if args.pr_number <= 0: raise SystemExit("--pr-number must be positive") - return inspect_and_approve(args.repo, args.pr_number) + return inspect_and_review(args.repo, args.pr_number) if __name__ == "__main__": @@ -292,4 +392,4 @@ def main(argv: list[str]) -> int: raise SystemExit(main(sys.argv[1:])) except RuntimeError as exc: print(str(exc), file=sys.stderr) - raise SystemExit(1) from exc + raise SystemExit(1) from exc \ No newline at end of file From bcf298222c3ae7d731092476a7cf51edde90f150 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 18:15:43 +0900 Subject: [PATCH 7/9] test(ci): cover Noema review gate --- scripts/ci/noema_review_gate.py | 23 ++- tests/test_noema_review_gate.py | 266 ++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 tests/test_noema_review_gate.py diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 3b232a41b..5b494cb0f 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -34,6 +34,7 @@ def run(args: Sequence[str], *, stdin: str | None = None) -> str: + """Run a command without invoking a shell and return stdout.""" if isinstance(args, str): raise TypeError("run() requires argv, not a shell command string") completed = subprocess.run( @@ -52,6 +53,7 @@ def run(args: Sequence[str], *, stdin: str | None = None) -> str: def split_repo(repo: str) -> tuple[str, str]: + """Split an owner/name repository string into owner and repository.""" owner, name = repo.split("/", 1) if not owner or not name: raise ValueError(f"repo must be owner/name, got {repo!r}") @@ -59,6 +61,7 @@ def split_repo(repo: str) -> tuple[str, str]: def graphql(query: str, **fields: str | int) -> dict[str, Any]: + """Call GitHub GraphQL through gh and return parsed JSON.""" args = ["gh", "api", "graphql", "-F", "query=@-"] for key, value in fields.items(): args.extend(["-F" if isinstance(value, int) else "-f", f"{key}={value}"]) @@ -114,6 +117,7 @@ def graphql(query: str, **fields: str | int) -> dict[str, Any]: 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) data = graphql(PR_QUERY, owner=owner, name=name, number=number) pr = data.get("data", {}).get("repository", {}).get("pullRequest") @@ -123,14 +127,17 @@ def fetch_pr(repo: str, number: int) -> dict[str, Any]: def review_author(review: dict[str, Any]) -> str: + """Return the normalized author login from a review node.""" return ((review.get("author") or {}).get("login") or "").strip() def review_commit(review: dict[str, Any]) -> str: + """Return the review commit oid from a review node.""" return ((review.get("commit") or {}).get("oid") or "").strip() def current_primary_approval(pr: dict[str, Any]) -> dict[str, Any] | None: + """Return the current-head OpenCode approval when it matches the contract.""" head_sha = str(pr.get("headRefOid") or "") reviews = (((pr.get("reviews") or {}).get("nodes")) or []) for review in reversed(reviews): @@ -146,6 +153,7 @@ def current_primary_approval(pr: dict[str, Any]) -> dict[str, Any] | None: def has_current_changes_requested(pr: dict[str, Any]) -> bool: + """Return whether the current head has any changes-requested review.""" head_sha = str(pr.get("headRefOid") or "") reviews = (((pr.get("reviews") or {}).get("nodes")) or []) for review in reversed(reviews): @@ -155,11 +163,13 @@ def has_current_changes_requested(pr: dict[str, Any]) -> bool: def has_unresolved_threads(pr: dict[str, Any]) -> bool: + """Return whether any non-outdated review thread is unresolved.""" threads = (((pr.get("reviewThreads") or {}).get("nodes")) or []) return any(not thread.get("isResolved") and not thread.get("isOutdated") for thread in threads) def check_label(node: dict[str, Any]) -> str: + """Return a human-readable label for a status context or check run.""" if node.get("__typename") == "StatusContext": return str(node.get("context") or "") workflow = ((((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {}).get("name") or "") @@ -168,6 +178,7 @@ def check_label(node: dict[str, Any]) -> str: def blocking_checks(pr: dict[str, Any]) -> list[str]: + """Return check contexts that should block Noema review.""" contexts = ((((pr.get("statusCheckRollup") or {}).get("contexts") or {}).get("nodes")) or []) blockers: list[str] = [] for node in contexts: @@ -189,6 +200,7 @@ def blocking_checks(pr: dict[str, Any]) -> list[str]: def existing_noema_review(pr: dict[str, Any], actor: str) -> bool: + """Return whether Noema already reviewed the current head.""" head_sha = str(pr.get("headRefOid") or "") marker = "")]}), + "noema", + ) + assert not noema.existing_noema_review(make_pr(reviews={"nodes": [review(commit="old", login="noema")]}), "noema") + + +def test_current_actor_fetch_diff_and_json_extraction(monkeypatch): + monkeypatch.setattr(noema, "run", lambda *args, **kwargs: "noema\n") + assert noema.current_actor() == "noema" + monkeypatch.setattr(noema, "run", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("no gh"))) + assert noema.current_actor() == "" + + monkeypatch.setattr(noema, "run", lambda *args, **kwargs: "x" * (noema.MAX_DIFF_CHARS + 5)) + diff, truncated = noema.fetch_diff("owner/repo", 1) + assert truncated + assert len(diff) == noema.MAX_DIFF_CHARS + + assert noema.extract_json_object('{"decision":"approve"}') == {"decision": "approve"} + assert noema.extract_json_object('prefix {"decision":"comment"} suffix') == {"decision": "comment"} + with pytest.raises(RuntimeError, match="did not contain"): + noema.extract_json_object("not-json") + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + +def test_call_llm_handles_configuration_and_verdicts(monkeypatch): + pr = make_pr() + monkeypatch.delenv("NOEMA_LLM_API_URL", raising=False) + monkeypatch.delenv("NOEMA_LLM_API_KEY", raising=False) + assert noema.call_llm("owner/repo", 1, pr, "diff", False) is None + + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example.test/chat") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") + monkeypatch.setenv("NOEMA_LLM_MODEL", "review-model") + seen = {} + + def fake_urlopen(request, timeout): + seen["url"] = request.full_url + seen["body"] = json.loads(request.data.decode("utf-8")) + return FakeResponse({"choices": [{"message": {"content": '{"decision":"approve","summary":"ok","findings":[]}'}}]}) + + monkeypatch.setattr(noema.urllib.request, "urlopen", fake_urlopen) + verdict = noema.call_llm("owner/repo", 1, pr, "diff", True) + assert verdict["decision"] == "approve" + assert seen["url"] == "https://llm.example.test/chat" + assert seen["body"]["model"] == "review-model" + + monkeypatch.setattr( + noema.urllib.request, + "urlopen", + lambda *args, **kwargs: FakeResponse({"choices": [{"message": {"content": '{"decision":"defer"}'}}]}), + ) + with pytest.raises(RuntimeError, match="unsupported decision"): + noema.call_llm("owner/repo", 1, pr, "diff", False) + + +def test_format_findings_and_submit_review(monkeypatch): + findings = noema.format_findings( + [ + {"severity": "high", "file": "a.py", "line": 3, "message": "bad"}, + {"severity": "low", "file": "b.py", "line": 0, "message": "note"}, + "skip", + {"message": ""}, + ] + ) + assert findings == ["- [high] a.py:3: bad", "- [low] b.py: note"] + + calls = [] + monkeypatch.setenv("NOEMA_REVIEW_TOKEN_SOURCE", "oidc") + monkeypatch.setattr(noema, "run", lambda args, stdin=None: calls.append((args, json.loads(stdin))) or "") + noema.submit_review( + "owner/repo", + 7, + make_pr(), + "noema", + {"decision": "request_changes", "summary": "fix it", "findings": [{"file": "a.py", "line": 1, "message": "bad"}]}, + ) + payload = calls[0][1] + assert payload["event"] == "REQUEST_CHANGES" + assert payload["commit_id"] == "head" + assert "Noema LLM review" in payload["body"] + assert "oidc" in payload["body"] + + calls.clear() + noema.submit_review("owner/repo", 7, make_pr(), "", {"decision": "comment"}) + assert calls[0][1]["event"] == "COMMENT" + assert "No blocking findings" in calls[0][1]["body"] + + +def test_inspect_and_review_skip_paths(monkeypatch): + marker_body = "OpenCode reviewed the current-head bounded evidence and found no blocking issues." + clean_pr = make_pr(reviews={"nodes": [review(body=marker_body)]}) + calls = [] + monkeypatch.setattr(noema, "fetch_pr", lambda repo, number: clean_pr) + monkeypatch.setattr(noema, "current_actor", lambda: "noema") + monkeypatch.setattr(noema, "fetch_diff", lambda repo, number: ("diff", False)) + monkeypatch.setattr(noema, "call_llm", lambda *args, **kwargs: {"decision": "approve", "summary": "ok", "findings": []}) + monkeypatch.setattr(noema, "submit_review", lambda *args, **kwargs: calls.append(args)) + + assert noema.inspect_and_review("owner/repo", 7) == 0 + assert calls + + cases = [ + (make_pr(), "noema"), + (make_pr(isDraft=True), "noema"), + (make_pr(reviews={"nodes": [review(login="noema", body="")]}), "noema"), + (make_pr(reviews={"nodes": [review("CHANGES_REQUESTED"), review(body=marker_body)]}), "noema"), + (make_pr(reviews={"nodes": [review(body=marker_body)]}, reviewThreads={"nodes": [{"isResolved": False, "isOutdated": False}]}), "noema"), + (make_pr(reviews={"nodes": [review(body=marker_body)]}, statusCheckRollup={"contexts": {"nodes": [{"__typename": "StatusContext", "context": "ci", "state": "FAILURE"}]}}), "noema"), + (clean_pr, "opencode-agent"), + ] + for pr, actor in cases: + calls.clear() + monkeypatch.setattr(noema, "fetch_pr", lambda repo, number, pr=pr: pr) + monkeypatch.setattr(noema, "current_actor", lambda actor=actor: actor) + assert noema.inspect_and_review("owner/repo", 7) == 0 + assert calls == [] + + calls.clear() + monkeypatch.setattr(noema, "fetch_pr", lambda repo, number: clean_pr) + monkeypatch.setattr(noema, "current_actor", lambda: "noema") + monkeypatch.setattr(noema, "call_llm", lambda *args, **kwargs: None) + assert noema.inspect_and_review("owner/repo", 7) == 0 + assert calls == [] + + +def test_parse_args_and_main(monkeypatch): + parsed = noema.parse_args(["--repo", "owner/repo", "--pr-number", "9"]) + assert parsed.repo == "owner/repo" + assert parsed.pr_number == 9 + + seen = [] + monkeypatch.setattr(noema, "inspect_and_review", lambda repo, number: seen.append((repo, number)) or 0) + assert noema.main(["--repo", "owner/repo", "--pr-number", "9"]) == 0 + assert seen == [("owner/repo", 9)] + + with pytest.raises(SystemExit, match="--pr-number must be positive"): + noema.main(["--repo", "owner/repo", "--pr-number", "0"]) From 3fb1e7e094e3115b250062ffce630e8719a564c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 18:18:52 +0900 Subject: [PATCH 8/9] test(ci): close Noema coverage gaps --- scripts/ci/noema_review_gate.py | 2 +- tests/test_noema_review_gate.py | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 5b494cb0f..b30622861 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -408,7 +408,7 @@ def main(argv: list[str]) -> int: return inspect_and_review(args.repo, args.pr_number) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover try: raise SystemExit(main(sys.argv[1:])) except RuntimeError as exc: diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 5702ccc01..7cee57d03 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -74,6 +74,8 @@ def test_review_state_helpers_cover_current_head_logic(): assert noema.review_commit(current) == "head" assert noema.review_commit({}) == "" assert noema.current_primary_approval(pr) == current + assert noema.current_primary_approval(make_pr(reviews={"nodes": [old, current]})) == current + assert noema.current_primary_approval(make_pr(reviews={"nodes": [review("COMMENTED", body=marker_body)]})) is None assert noema.current_primary_approval(make_pr(reviews={"nodes": [review(login="human", body=marker_body)]})) is None assert noema.has_current_changes_requested(make_pr(reviews={"nodes": [review("CHANGES_REQUESTED")]})) assert not noema.has_current_changes_requested(make_pr(reviews={"nodes": [review("CHANGES_REQUESTED", commit="old")]})) @@ -108,7 +110,19 @@ def test_check_helpers_and_existing_noema_review(): assert noema.check_label(status_context) == "ci" assert noema.check_label(check_run) == "CI / build" blockers = noema.blocking_checks( - make_pr(statusCheckRollup={"contexts": {"nodes": [status_context, check_run, failed_run, running_run]}}) + make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + status_context, + check_run, + failed_run, + running_run, + {"__typename": "CheckRun", "name": "Required Noema Review", "status": "IN_PROGRESS"}, + ] + } + } + ) ) assert "ci: FAILURE" in blockers assert "CI / lint: FAILURE" in blockers @@ -117,6 +131,7 @@ def test_check_helpers_and_existing_noema_review(): make_pr(reviews={"nodes": [review(login="noema", body="")]}), "noema", ) + assert not noema.existing_noema_review(make_pr(reviews={"nodes": [review("DISMISSED", login="noema")]}), "noema") assert not noema.existing_noema_review(make_pr(reviews={"nodes": [review(commit="old", login="noema")]}), "noema") From cc62cb263a5c4f4dae376ad873b63971fa5fe9a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 30 Jun 2026 18:21:31 +0900 Subject: [PATCH 9/9] test(ci): cover final Noema gate branch --- tests/test_noema_review_gate.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 7cee57d03..5c3ab4f14 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -10,6 +10,7 @@ def make_pr(**overrides): + """Build a minimal pull request payload for Noema tests.""" value = { "number": 7, "title": "Noema", @@ -25,6 +26,7 @@ def make_pr(**overrides): def review(state="APPROVED", commit="head", login="opencode-agent", body="Result: APPROVE"): + """Build a minimal review node for Noema tests.""" return { "state": state, "body": body, @@ -74,7 +76,7 @@ def test_review_state_helpers_cover_current_head_logic(): assert noema.review_commit(current) == "head" assert noema.review_commit({}) == "" assert noema.current_primary_approval(pr) == current - assert noema.current_primary_approval(make_pr(reviews={"nodes": [old, current]})) == current + assert noema.current_primary_approval(make_pr(reviews={"nodes": [old]})) is None assert noema.current_primary_approval(make_pr(reviews={"nodes": [review("COMMENTED", body=marker_body)]})) is None assert noema.current_primary_approval(make_pr(reviews={"nodes": [review(login="human", body=marker_body)]})) is None assert noema.has_current_changes_requested(make_pr(reviews={"nodes": [review("CHANGES_REQUESTED")]})) @@ -153,16 +155,22 @@ def test_current_actor_fetch_diff_and_json_extraction(monkeypatch): class FakeResponse: + """Small context-manager response for urllib monkeypatches.""" + def __init__(self, payload): + """Store a JSON-serializable response payload.""" self.payload = payload def __enter__(self): + """Return the response for with-statement use.""" return self def __exit__(self, *args): + """Propagate exceptions from the with-statement body.""" return False def read(self): + """Return the payload as encoded JSON bytes.""" return json.dumps(self.payload).encode("utf-8")