* feat(tools): add post-review and post-docs-review tools * refactor(agents): use post-review/post-docs-review tools in reviewer and docs-reviewer * feat(permissions): add post_review and post_docs_review to agent.tools map * test(tools): add tests for post-review and post-docs-review tools * docs(handoff): add handoff ADR and project-map for review posting tools * docs(handoff): set PR number * docs: fix PR#45→PR#46 refs in project map --------- Co-authored-by: opencode-agent <agent@slaid098.dev>
237 lines
9.3 KiB
Python
237 lines
9.3 KiB
Python
"""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=<V>",
|
|
(b) comment heading: body passed to gh contains "## Code Review Summary",
|
|
(c) comment verdict: body passed to gh contains "### Verdict: <V>",
|
|
(d) spawnSync args: gh pr comment N --body <comment> --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: <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 <N> --body <comment> --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}"
|