#!/usr/bin/env python3 import json import os import re import subprocess import sys import tempfile import textwrap import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "opencode-code-review" class OpenCodeReviewScriptTest(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.workdir = Path(self.tmp.name) self.calls_path = self.workdir / "gh-calls.jsonl" self.output_path = self.workdir / "output.txt" self.files_path = self.workdir / "files.txt" self.status_path = self.workdir / "status.txt" self.stdout_path = self.workdir / "stdout.txt" self.stderr_path = self.workdir / "stderr.txt" self.summary_path = self.workdir / "summary.md" self.patch_path = self.workdir / "review.patch" self.files_path.write_text("src/app.ts\n", encoding="utf-8") self.status_path.write_text("0\n", encoding="utf-8") self.stdout_path.write_text("stdout line\n", encoding="utf-8") self.stderr_path.write_text("stderr line\n", encoding="utf-8") self.summary_path.write_text("", encoding="utf-8") self.patch_path.write_text("", encoding="utf-8") self.fake_gh = self.write_fake_gh() def write_fake_gh(self) -> Path: fake_gh = self.workdir / "gh" fake_gh.write_text( textwrap.dedent( """\ #!/usr/bin/env python3 import json import os import sys from pathlib import Path calls_path = Path(os.environ["GH_CALLS"]) args = sys.argv[1:] calls_path.write_text( calls_path.read_text() + json.dumps(args) + "\\n" if calls_path.exists() else json.dumps(args) + "\\n" ) if "comments?per_page=100" in " ".join(args): print("[]") raise SystemExit(0) joined = " ".join(args) if "/pulls/" in joined and "/comments" in joined and os.environ.get("INLINE_FAIL") == "1": print("line is not part of the diff", file=sys.stderr) raise SystemExit(1) if "/reviews" in joined and os.environ.get("TERMINAL_REVIEW_FAIL") == "1": print("terminal review unavailable", file=sys.stderr) raise SystemExit(1) if "/issues/" in joined and os.environ.get("ISSUE_COMMENT_FAIL") == "1": print("issue comments unavailable", file=sys.stderr) raise SystemExit(1) print("{}") """ ), encoding="utf-8", ) fake_gh.chmod(0o755) return fake_gh def run_script( self, *, output: str, inline_fail: bool = False, issue_comment_fail: bool = False, terminal_review_fail: bool = False, gh_bin: Path | None = None, include_patch: bool = False, ) -> subprocess.CompletedProcess[str]: self.calls_path.unlink(missing_ok=True) self.output_path.write_text(output, encoding="utf-8") env = { **os.environ, "GH_BIN": str(gh_bin or self.fake_gh), "GH_CALLS": str(self.calls_path), "GITHUB_REPOSITORY": "kdlbs/kandev", "PR_NUMBER": "42", "HEAD_SHA": "0123456789abcdef", "GITHUB_RUN_ID": "27340000005", "GITHUB_RUN_ATTEMPT": "2", "OPENCODE_MODEL": "opencode-go/minimax-m3", } if inline_fail: env["INLINE_FAIL"] = "1" if issue_comment_fail: env["ISSUE_COMMENT_FAIL"] = "1" if terminal_review_fail: env["TERMINAL_REVIEW_FAIL"] = "1" command = [ sys.executable, str(SCRIPT), "post-findings", "--output", str(self.output_path), "--files", str(self.files_path), "--opencode-status", str(self.status_path), "--stdout", str(self.stdout_path), "--stderr", str(self.stderr_path), "--summary", str(self.summary_path), ] if include_patch: command.extend(["--patch", str(self.patch_path)]) return subprocess.run( command, text=True, capture_output=True, env=env, check=False, ) def read_calls(self) -> list[list[str]]: if not self.calls_path.exists(): return [] return [json.loads(line) for line in self.calls_path.read_text().splitlines()] def bodies(self) -> list[str]: bodies = [] for call in self.read_calls(): for index, arg in enumerate(call): if arg == "-f" and index + 1 < len(call) and call[index + 1].startswith("body="): bodies.append(call[index + 1][len("body=") :]) return bodies def terminal_review_calls(self) -> list[list[str]]: return [call for call in self.read_calls() if any("/reviews" in arg for arg in call)] def terminal_review_body(self, call: list[str]) -> str: return next(arg[len("body=") :] for arg in call if arg.startswith("body=")) def test_empty_findings_post_trusted_exact_head_clean_terminal_review(self) -> None: result = self.run_script(output="[]\n") self.assertEqual(result.returncode, 0, result.stderr) terminal_calls = self.terminal_review_calls() self.assertEqual(len(terminal_calls), 1, terminal_calls) call = terminal_calls[0] self.assertIn("repos/kdlbs/kandev/pulls/42/reviews", call) self.assertIn("commit_id=0123456789abcdef", call) self.assertIn("event=COMMENT", call) body = self.terminal_review_body(call) self.assertIn("", body) self.assertIn("", body) self.assertIn("OpenCode review complete", body) def test_nonempty_findings_post_blocked_terminal_review_without_clean_marker(self) -> None: findings = [{"path": "src/app.ts", "line": 99, "title": "Bad line", "body": "This line moved."}] result = self.run_script(output=f"{json.dumps(findings)}\n") self.assertEqual(result.returncode, 0, result.stderr) terminal_calls = self.terminal_review_calls() self.assertEqual(len(terminal_calls), 1, terminal_calls) body = self.terminal_review_body(terminal_calls[0]) self.assertNotIn("", body) self.assertIn("", body) self.assertIn("action required", body.lower()) def test_diagnostic_paths_never_post_clean_terminal_review(self) -> None: for output, status in [("not findings", "0\n"), ("[]", "2\n")]: with self.subTest(output=output, status=status): self.status_path.write_text(status, encoding="utf-8") result = self.run_script(output=output) self.assertNotEqual(result.returncode, 0) terminal_calls = self.terminal_review_calls() self.assertEqual(len(terminal_calls), 1, terminal_calls) self.assertNotIn("", self.terminal_review_body(terminal_calls[0])) self.assertIn("", self.terminal_review_body(terminal_calls[0])) def test_terminal_review_failure_fails_the_step(self) -> None: result = self.run_script( output="[]\n", terminal_review_fail=True ) self.assertNotEqual(result.returncode, 0) self.assertIn("Could not post OpenCode terminal review", result.stderr) def test_diagnostic_terminal_review_failure_fails_the_step(self) -> None: result = self.run_script(output="not findings", terminal_review_fail=True) self.assertNotEqual(result.returncode, 0) self.assertIn("Could not post OpenCode terminal review", result.stderr) def test_missing_findings_block_posts_diagnostic_and_fails(self) -> None: result = self.run_script(output="OpenCode refused to read external_directory (/tmp/*)\n") self.assertNotEqual(result.returncode, 0) self.assertIn("did not produce parseable findings", result.stderr) self.assertTrue( any("" in body for body in self.bodies()), "expected a stable diagnostic PR comment", ) self.assertIn("No parseable findings block", self.summary_path.read_text()) def test_invalid_findings_json_posts_diagnostic_and_fails(self) -> None: result = self.run_script(output="{}\n") self.assertNotEqual(result.returncode, 0) self.assertIn("did not produce parseable findings", result.stderr) self.assertTrue(any("" in body for body in self.bodies())) self.assertIn("not an array", self.summary_path.read_text()) def test_fenced_json_findings_are_accepted(self) -> None: result = self.run_script(output="```json\n[]\n```\n") self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(any("" in body for body in self.bodies())) def test_missing_gh_binary_reports_gracefully(self) -> None: result = self.run_script( output="[]\n", gh_bin=self.workdir / "missing-gh", ) self.assertNotEqual(result.returncode, 0) self.assertIn("Binary not found", result.stderr) def test_valid_empty_array_does_not_post_legacy_no_findings_issue_comment(self) -> None: result = self.run_script(output="[]\n") self.assertEqual(result.returncode, 0, result.stderr) bodies = self.bodies() self.assertFalse(any("" in body for body in bodies)) self.assertFalse(any("" in body for body in bodies)) def test_parse_findings_rejects_prefix_suffix_duplicate_and_empty_then_nonempty_blocks(self) -> None: invalid_outputs = [ "prefix []", "[] suffix", "[][]", "[][{\"path\":\"src/app.ts\",\"line\":1,\"title\":\"bad\",\"body\":\"bad\"}]", ] for output in invalid_outputs: with self.subTest(output=output): result = self.run_script(output=output) self.assertNotEqual(result.returncode, 0) terminal_calls = self.terminal_review_calls() self.assertEqual(len(terminal_calls), 1, terminal_calls) self.assertNotIn("", self.terminal_review_body(terminal_calls[0])) def test_inline_failures_are_posted_as_one_fallback_comment(self) -> None: result = self.run_script( output=textwrap.dedent( """\ [{"path":"src/app.ts","line":99,"title":"Bad line","body":"This line moved."}] """ ), inline_fail=True, ) self.assertEqual(result.returncode, 0, result.stderr) bodies = self.bodies() self.assertTrue(any("" in body for body in bodies)) self.assertTrue(any("src/app.ts:99" in body for body in bodies)) def test_context_lines_are_commentable_when_patch_is_available(self) -> None: self.patch_path.write_text( textwrap.dedent( """\ diff --git a/src/app.ts b/src/app.ts index 1111111..2222222 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,5 +8,6 @@ const before = 1; const existing = 2; +const added = 3; const after = 4; """ ), encoding="utf-8", ) findings = [ {"path": "src/app.ts", "line": 9, "title": "Context line", "body": "This is unchanged context."}, {"path": "src/app.ts", "line": 10, "title": "Added line", "body": "This is added."}, ] result = self.run_script( output=f"{json.dumps(findings)}\n", include_patch=True, ) self.assertEqual(result.returncode, 0, result.stderr) pull_comment_calls = [call for call in self.read_calls() if any("/pulls/" in arg and "/comments" in arg for arg in call)] self.assertEqual(len(pull_comment_calls), 2, pull_comment_calls) self.assertTrue(any("line=9" in arg for call in pull_comment_calls for arg in call)) self.assertTrue(any("line=10" in arg for call in pull_comment_calls for arg in call)) self.assertFalse(any("" in body for body in self.bodies())) self.assertIn("Inline comments skipped before posting: `0`", self.summary_path.read_text()) def test_lines_outside_patch_hunks_are_prefiltered_to_fallback(self) -> None: self.patch_path.write_text( textwrap.dedent( """\ diff --git a/src/app.ts b/src/app.ts index 1111111..2222222 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,5 +8,6 @@ const before = 1; const existing = 2; +const added = 3; const after = 4; """ ), encoding="utf-8", ) findings = [ {"path": "src/app.ts", "line": 99, "title": "Outside hunk", "body": "This is not in the diff."}, {"path": "src/app.ts", "line": 10, "title": "Added line", "body": "This is added."}, ] result = self.run_script( output=f"{json.dumps(findings)}\n", include_patch=True, ) self.assertEqual(result.returncode, 0, result.stderr) pull_comment_calls = [call for call in self.read_calls() if any("/pulls/" in arg and "/comments" in arg for arg in call)] self.assertEqual(len(pull_comment_calls), 1, pull_comment_calls) self.assertTrue(any("line=10" in arg for arg in pull_comment_calls[0])) self.assertFalse(any("line=99" in arg for call in self.read_calls() for arg in call)) bodies = self.bodies() self.assertTrue(any("" in body for body in bodies)) self.assertTrue(any("src/app.ts:99" in body for body in bodies)) self.assertFalse(any("src/app.ts:10" in body for body in bodies)) self.assertIn("Inline comments skipped before posting: `1`", self.summary_path.read_text()) def test_deleted_lines_starting_with_dashes_do_not_shift_right_lines(self) -> None: self.patch_path.write_text( textwrap.dedent( """\ diff --git a/src/app.ts b/src/app.ts index 1111111..2222222 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,4 +8,3 @@ const before = 1; --- removed separator const after = 2; """ ), encoding="utf-8", ) findings = [ {"path": "src/app.ts", "line": 9, "title": "After context", "body": "Still commentable."}, {"path": "src/app.ts", "line": 10, "title": "Shifted line", "body": "Should be outside hunk."}, ] result = self.run_script( output=f"{json.dumps(findings)}\n", include_patch=True, ) self.assertEqual(result.returncode, 0, result.stderr) pull_comment_calls = [call for call in self.read_calls() if any("/pulls/" in arg and "/comments" in arg for arg in call)] self.assertEqual(len(pull_comment_calls), 1, pull_comment_calls) self.assertTrue(any("line=9" in arg for arg in pull_comment_calls[0])) self.assertFalse(any("line=10" in arg for call in self.read_calls() for arg in call)) fallback = next(body for body in self.bodies() if "" in body) self.assertIn("src/app.ts:10", fallback) def test_integral_float_line_values_are_normalized(self) -> None: result = self.run_script( output=textwrap.dedent( """\ [{"path":"src/app.ts","line":99.0,"title":"Bad line","body":"This line moved."}] """ ), ) self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(any("-F" in call and "line=99" in call for call in self.read_calls())) def test_invalid_path_values_are_logged(self) -> None: result = self.run_script( output=textwrap.dedent( """\ [{"path":42,"line":99,"title":"Bad line","body":"This line moved."}] """ ), ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("invalid path value", result.stdout) def test_inline_findings_beyond_limit_are_preserved_in_fallback_comment(self) -> None: findings = [ {"path": "src/app.ts", "line": index + 1, "title": f"Finding {index + 1}", "body": "body"} for index in range(30) ] result = self.run_script( output=f"{json.dumps(findings)}\n", inline_fail=True, ) self.assertEqual(result.returncode, 0, result.stderr) bodies = self.bodies() self.assertTrue(any("" in body for body in bodies)) self.assertTrue(any("src/app.ts:1" in body for body in bodies)) self.assertTrue(any("src/app.ts:30" in body for body in bodies)) self.assertTrue(any("## Inline placement unavailable" in body for body in bodies)) self.assertTrue(any("## Additional findings beyond inline comment limit" in body for body in bodies)) summary = self.summary_path.read_text() self.assertIn("Inline comments rejected: `20`", summary) self.assertIn("Findings beyond inline limit: `10`", summary) self.assertIn("Fallback findings included in comment: `30`", summary) self.assertIn("Fallback findings omitted from comment: `0`", summary) def test_fallback_comment_reports_findings_omitted_by_body_limit(self) -> None: findings = [ {"path": "src/app.ts", "line": index + 1, "title": f"Finding {index + 1}", "body": "x" * 4000} for index in range(30) ] result = self.run_script( output=f"{json.dumps(findings)}\n", inline_fail=True, ) self.assertEqual(result.returncode, 0, result.stderr) bodies = self.bodies() self.assertTrue(any("Additional fallback findings omitted" in body for body in bodies)) self.assertTrue(all(len(body) <= 60000 for body in bodies)) summary = self.summary_path.read_text() included_match = re.search(r"Fallback findings included in comment: `([1-9][0-9]*)`", summary) self.assertIsNotNone(included_match, summary) fallback = next(body for body in bodies if "" in body) self.assertEqual(fallback.count("### src/app.ts:"), int(included_match.group(1))) omitted_match = re.search(r"Fallback findings omitted from comment: `([1-9][0-9]*)`", summary) self.assertIsNotNone(omitted_match, summary) def test_fallback_comment_failure_fails_the_step(self) -> None: result = self.run_script( output=textwrap.dedent( """\ [{"path":"src/app.ts","line":99,"title":"Bad line","body":"This line moved."}] """ ), inline_fail=True, issue_comment_fail=True, ) self.assertNotEqual(result.returncode, 0) self.assertIn("Could not post OpenCode comment", result.stderr) if __name__ == "__main__": unittest.main()