diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml new file mode 100644 index 000000000..ff885e317 --- /dev/null +++ b/.github/workflows/noema-review.yml @@ -0,0 +1,171 @@ +name: Required Noema 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: >- + 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) || + github.event.inputs.pr_number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + checks: read + id-token: write + +jobs: + noema-review: + name: noema-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 || '' }} + steps: + - name: Resolve trusted Noema 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/noema-review.yml@*) + trusted_ref="${WORKFLOW_REF##*@}" + ;; + esac + printf 'ref=%s\n' "$trusted_ref" >>"$GITHUB_OUTPUT" + + - name: Checkout trusted Noema 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: Exchange Noema app token + id: noema_app_token + env: + OIDC_AUDIENCE: ${{ vars.NOEMA_OIDC_AUDIENCE || 'cwl-noema-review' }} + TOKEN_EXCHANGE_URL: ${{ vars.NOEMA_TOKEN_EXCHANGE_URL || '' }} + run: | + set -euo pipefail + + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + if [ -z "${TOKEN_EXCHANGE_URL:-}" ]; then + 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 "Noema 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 "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 "Noema 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 "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 "Noema 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: Run Noema LLM review and submit verdict + env: + GH_TOKEN: ${{ steps.noema_app_token.outputs.token }} + 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 + echo "No pull request number was available for this event; skipping." + exit 0 + fi + if [ -z "${GH_TOKEN:-}" ]; then + 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" \ No newline at end of file diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py new file mode 100644 index 000000000..b30622861 --- /dev/null +++ b/scripts/ci/noema_review_gate.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Run Noema LLM review and submit a non-OpenCode PR review verdict.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import urllib.request +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", + "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: + """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( + 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 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}") + return owner, name + + +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}"]) + 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 + body + 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]: + """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") + if not pr: + raise RuntimeError(f"PR #{number} was not found in {repo}") + return pr + + +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): + 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: + """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): + 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: + """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 "") + name = str(node.get("name") or "") + return f"{workflow} / {name}" if workflow else name + + +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: + 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_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 = "", + ] + ) + payload = { + "commit_id": head_sha, + "event": event, + "body": body, + } + run( + ["gh", "api", "-X", "POST", f"repos/{repo}/pulls/{number}/reviews", "--input", "-"], + stdin=json.dumps(payload), + ) + print(f"Noema {event} review submitted for {repo}#{number} at {head_sha}.") + + +def inspect_and_review(repo: str, number: int) -> int: + """Inspect PR state and submit Noema's LLM review when gates are clean.""" + 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 review skipped so GitHub receives an independent reviewer." + ) + return 0 + if pr.get("isDraft"): + print("PR is draft; Noema review skipped.") + return 0 + 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 review skipped.") + return 0 + if has_current_changes_requested(pr): + print("Current head has requested changes; Noema review skipped.") + return 0 + if has_unresolved_threads(pr): + print("PR has unresolved review threads; Noema review skipped.") + return 0 + blockers = blocking_checks(pr) + if blockers: + print("Blocking checks remain; Noema review skipped:") + for blocker in blockers: + print(f"- {blocker}") + return 0 + 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 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse Noema review gate command-line arguments.""" + 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: + """Run the Noema review gate command.""" + args = parse_args(argv) + if args.pr_number <= 0: + raise SystemExit("--pr-number must be positive") + return inspect_and_review(args.repo, args.pr_number) + + +if __name__ == "__main__": # pragma: no cover + try: + raise SystemExit(main(sys.argv[1:])) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) from exc diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py new file mode 100644 index 000000000..5c3ab4f14 --- /dev/null +++ b/tests/test_noema_review_gate.py @@ -0,0 +1,289 @@ +import io +import json +import os +import sys +import urllib.error + +import pytest + +from scripts.ci import noema_review_gate as noema + + +def make_pr(**overrides): + """Build a minimal pull request payload for Noema tests.""" + value = { + "number": 7, + "title": "Noema", + "body": "", + "isDraft": False, + "headRefOid": "head", + "reviews": {"nodes": []}, + "reviewThreads": {"nodes": []}, + "statusCheckRollup": {"contexts": {"nodes": []}}, + } + value.update(overrides) + return value + + +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, + "author": {"login": login}, + "commit": {"oid": commit}, + } + + +def test_run_split_repo_graphql_and_fetch_pr(monkeypatch): + assert noema.run([sys.executable, "-c", "print('ok')"]).strip() == "ok" + with pytest.raises(TypeError): + noema.run("echo unsafe") # type: ignore[arg-type] + with pytest.raises(RuntimeError): + noema.run([sys.executable, "-c", "import sys; sys.exit(5)"]) + + assert noema.split_repo("owner/repo") == ("owner", "repo") + with pytest.raises(ValueError): + noema.split_repo("owner") + with pytest.raises(ValueError): + noema.split_repo("/repo") + + calls = [] + + def fake_run(args, stdin=None): + calls.append((args, stdin)) + 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} + + monkeypatch.setattr(noema, "graphql", lambda *args, **kwargs: {"data": {"repository": {"pullRequest": None}}}) + with pytest.raises(RuntimeError, match="was not found"): + noema.fetch_pr("owner/repo", 8) + + +def test_review_state_helpers_cover_current_head_logic(): + marker_body = "OpenCode reviewed the current-head bounded evidence and found no blocking issues." + current = review(body=marker_body) + old = review(commit="old", body=marker_body) + pr = make_pr(reviews={"nodes": [old, current]}) + + assert noema.review_author(current) == "opencode-agent" + assert noema.review_author({}) == "" + 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]})) 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")]})) + assert not noema.has_current_changes_requested(make_pr(reviews={"nodes": [review("CHANGES_REQUESTED", commit="old")]})) + assert noema.has_unresolved_threads(make_pr(reviewThreads={"nodes": [{"isResolved": False, "isOutdated": False}]})) + assert not noema.has_unresolved_threads(make_pr(reviewThreads={"nodes": [{"isResolved": False, "isOutdated": True}]})) + + +def test_check_helpers_and_existing_noema_review(): + status_context = {"__typename": "StatusContext", "context": "ci", "state": "FAILURE"} + check_run = { + "__typename": "CheckRun", + "name": "build", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "checkSuite": {"workflowRun": {"workflow": {"name": "CI"}}}, + } + failed_run = { + "__typename": "CheckRun", + "name": "lint", + "status": "COMPLETED", + "conclusion": "FAILURE", + "checkSuite": {"workflowRun": {"workflow": {"name": "CI"}}}, + } + running_run = { + "__typename": "CheckRun", + "name": "slow", + "status": "IN_PROGRESS", + "conclusion": None, + "checkSuite": {"workflowRun": {"workflow": {"name": "CI"}}}, + } + + 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, + {"__typename": "CheckRun", "name": "Required Noema Review", "status": "IN_PROGRESS"}, + ] + } + } + ) + ) + assert "ci: FAILURE" in blockers + assert "CI / lint: FAILURE" in blockers + assert "CI / slow: IN_PROGRESS" in blockers + assert noema.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") + + +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: + """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") + + +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"])