"""Tests for .opencode/tools/post-review.ts — the post-review custom tool. Mirrors tests/test_commit_tool.py / test_create_pr_tool.py: exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs`` using the ``exec_stub_json`` mode (multi-arg tools). The loader is parameterized via the ``TS_FILE`` env var. These tests set ``TS_FILE=.opencode/tools/post-review.ts``. Modes used: - ``load`` — sanity-check that the tool loads and declares pr_number, verdict, body args. - ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: (a) success path: valid verdict (APPROVE/REQUEST_CHANGES/NEEDS_DISCUSSION) → "Review posted on PR #N: verdict=", (b) comment heading: body passed to gh contains "## Code Review Summary", (c) comment verdict: body passed to gh contains "### Verdict: ", (d) spawnSync args: gh pr comment N --body --repo slaid098/opencode-config. post-review.ts makes 1 spawnSync call (gh pr comment) on all paths. Note on enum validation: the zod enum (tool.schema.enum(VERDICTS)) validates verdicts at the opencode layer. The _ts_loader.mjs shim does NOT validate (it returns a chainable builder without checks). Therefore the "invalid verdict" test documents that execute() itself does not validate — the guard is the zod schema in the real opencode runtime. The test passes a bad verdict and verifies the tool still builds the comment (no inline validation). """ import json import os import subprocess from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs" TS_FILE = REPO_ROOT / ".opencode" / "tools" / "post-review.ts" TS_FILE_REL = ".opencode/tools/post-review.ts" COMMENT_URL = "https://github.com/slaid098/opencode-config/issues/45#issuecomment-1" COMMENT_OK_RESPONSE = {"status": 0, "stdout": COMMENT_URL + "\n", "stderr": ""} EXPECTED_APPROVE = "Review posted on PR #45: verdict=APPROVE" EXPECTED_REQUEST_CHANGES = "Review posted on PR #45: verdict=REQUEST_CHANGES" EXPECTED_NEEDS_DISCUSSION = "Review posted on PR #45: verdict=NEEDS_DISCUSSION" EXPECTED_APROVE_TYPO = "Review posted on PR #45: verdict=APROVE" def _run_loader(*args: str) -> dict: """Invoke the loader with TS_FILE env set to post-review.ts and parse JSON stdout.""" env = {**os.environ, "TS_FILE": TS_FILE_REL} proc = subprocess.run( ["node", str(LOADER), *args], capture_output=True, text=True, check=False, cwd=str(REPO_ROOT), timeout=60, env=env, ) if proc.returncode != 0: raise RuntimeError( f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n" f"stdout: {proc.stdout}\nstderr: {proc.stderr}" ) return json.loads(proc.stdout) def _run_exec(args: dict, responses: list[dict]) -> dict: """Helper: exec_stub_json mode with JSON args + sequential stub responses.""" return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses)) def test_loader_can_load_tool(): """Sanity: post-review.ts loads and declares pr_number, verdict, body args.""" if not TS_FILE.exists(): pytest.skip("post-review.ts not present") out = _run_loader("load") assert "description" in out args = out["args"] assert "pr_number" in args, f"missing pr_number arg: {args}" assert "verdict" in args, f"missing verdict arg: {args}" assert "body" in args, f"missing body arg: {args}" def test_valid_approve(): """execute() with APPROVE verdict returns 'Review posted on PR #N: verdict=APPROVE'.""" out = _run_exec( {"pr_number": 45, "verdict": "APPROVE", "body": "Good changes."}, [COMMENT_OK_RESPONSE], ) result = out["result"] assert result == EXPECTED_APPROVE, f"expected success, got: {result!r}" def test_valid_request_changes(): """execute() with REQUEST_CHANGES verdict returns success.""" out = _run_exec( {"pr_number": 45, "verdict": "REQUEST_CHANGES", "body": "Critical bug."}, [COMMENT_OK_RESPONSE], ) result = out["result"] assert result == EXPECTED_REQUEST_CHANGES, f"expected success, got: {result!r}" def test_valid_needs_discussion(): """execute() with NEEDS_DISCUSSION verdict returns success.""" out = _run_exec( {"pr_number": 45, "verdict": "NEEDS_DISCUSSION", "body": "Questions."}, [COMMENT_OK_RESPONSE], ) result = out["result"] assert result == EXPECTED_NEEDS_DISCUSSION, f"expected success, got: {result!r}" def test_invalid_verdict_not_validated_by_execute(): """execute() does NOT validate verdict inline — zod enum does at opencode layer. The _ts_loader.mjs shim does not validate (chainable builder without checks). Passing a typo "APROVE" (missing a P) builds a comment with the bad verdict. The real guard is tool.schema.enum(VERDICTS) in the opencode runtime. This test documents that execute() itself is permissive — validation is delegated to the zod schema, not duplicated inside execute(). """ out = _run_exec( {"pr_number": 45, "verdict": "APROVE", "body": "typo verdict"}, [COMMENT_OK_RESPONSE], ) result = out["result"] # Tool runs without error — no inline validation. assert result == EXPECTED_APROVE_TYPO, f"expected permissive, got: {result!r}" def test_comment_has_heading(): """Comment body passed to gh contains '## Code Review Summary' heading.""" out = _run_exec( {"pr_number": 45, "verdict": "APPROVE", "body": "Review body."}, [COMMENT_OK_RESPONSE], ) calls = out["calls"] assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" args = calls[0]["args"] body_idx = args.index("--body") + 1 comment = args[body_idx] assert comment.startswith("## Code Review Summary\n"), ( f"expected heading at start, got: {comment!r}" ) def test_comment_has_verdict(): """Comment body passed to gh contains '### Verdict: '.""" out = _run_exec( {"pr_number": 45, "verdict": "APPROVE", "body": "Review body."}, [COMMENT_OK_RESPONSE], ) calls = out["calls"] args = calls[0]["args"] body_idx = args.index("--body") + 1 comment = args[body_idx] assert "### Verdict: APPROVE" in comment, f"expected verdict line, got: {comment!r}" def test_comment_has_request_changes_verdict(): """Comment body with REQUEST_CHANGES contains '### Verdict: REQUEST_CHANGES'.""" out = _run_exec( {"pr_number": 45, "verdict": "REQUEST_CHANGES", "body": "Critical bug."}, [COMMENT_OK_RESPONSE], ) calls = out["calls"] args = calls[0]["args"] body_idx = args.index("--body") + 1 comment = args[body_idx] assert "### Verdict: REQUEST_CHANGES" in comment, f"expected verdict line, got: {comment!r}" def test_comment_has_needs_discussion_verdict(): """Comment body with NEEDS_DISCUSSION contains '### Verdict: NEEDS_DISCUSSION'.""" out = _run_exec( {"pr_number": 45, "verdict": "NEEDS_DISCUSSION", "body": "Questions."}, [COMMENT_OK_RESPONSE], ) calls = out["calls"] args = calls[0]["args"] body_idx = args.index("--body") + 1 comment = args[body_idx] assert "### Verdict: NEEDS_DISCUSSION" in comment, f"expected verdict line, got: {comment!r}" def test_spawnsync_args(): """spawnSync called with gh pr comment --body --repo slaid098/opencode-config.""" out = _run_exec( {"pr_number": 45, "verdict": "APPROVE", "body": "Review body."}, [COMMENT_OK_RESPONSE], ) calls = out["calls"] assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" call = calls[0] assert call["cmd"] == "gh", f"expected cmd 'gh', got: {call['cmd']!r}" args = call["args"] assert args[0] == "pr", f"expected first arg 'pr', got: {args[0]!r}" assert args[1] == "comment", f"expected second arg 'comment', got: {args[1]!r}" assert args[2] == "45", f"expected PR number '45', got: {args[2]!r}" assert "--body" in args, "missing --body flag" assert "--repo" in args, "missing --repo flag" repo_idx = args.index("--repo") + 1 assert args[repo_idx] == "slaid098/opencode-config", ( f"expected repo 'slaid098/opencode-config', got: {args[repo_idx]!r}" ) def test_execute_uses_cwd_from_context(): """execute passes cwd=context.worktree to spawnSync (ADR-023 pattern).""" out = _run_exec( {"pr_number": 45, "verdict": "APPROVE", "body": "Review body."}, [COMMENT_OK_RESPONSE], ) calls = out["calls"] assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" opts = calls[0]["opts"] assert opts is not None, "spawnSync called without opts — expected cwd kwarg" assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}" assert opts["cwd"] == str(REPO_ROOT), ( f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}" ) def test_gh_failure_returns_error(): """execute() with gh exit non-zero returns error message with exit code.""" fail_response = {"status": 1, "stdout": "", "stderr": "gh: not authenticated"} out = _run_exec( {"pr_number": 45, "verdict": "APPROVE", "body": "Review body."}, [fail_response], ) result = out["result"] assert "post-review failed" in result, f"expected failure message, got: {result!r}" assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}"