opencode-config/tests/test_merge_pr_tool.py
Sergey 15fc7d014d
refactor(tools): add repo parameter + shared module + tunnel tests (#65)
* refactor(tools): extract shared module for gh spawnSync logic

* feat(tools): add repo parameter to 5 GitHub tools

* test(tools): add tunnel tool tests

* test(tools): add repo parameter test cases for 5 tools

* docs(handoff): scaffold handoff and ADR for PR

* docs(handoff): set PR number

* docs(project-map): update after PR#65 structural changes

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-25 19:19:19 +03:00

185 lines
7.8 KiB
Python

"""Tests for .opencode/tools/merge-pr.ts — the merge-pr custom tool.
Mirrors tests/test_post_review_tool.py / test_commit_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/merge-pr.ts``.
Modes used:
- ``load`` — sanity-check that the tool loads and declares ``pr_number`` and
the optional ``repo`` args.
- ``exec_stub_json`` — call execute with a stubbed spawnSync to verify:
(a) success path: valid pr_number → "PR #N merged successfully (squash,
branch deleted).",
(b) spawnSync args: gh pr merge N --squash --delete-branch (no --repo when
repo arg omitted — gh auto-detects from context.worktree, ADR-025),
(c) repo parameter: explicit repo → "--repo <owner/name>" prepended;
omitted → no --repo (backward-compatible auto-detect, ADR-027),
(d) failure: gh exit non-zero → "⚠️ merge_pr failed for PR #N (exit K): ...".
merge-pr.ts makes 1 spawnSync call (gh pr merge) on all paths.
"""
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" / "merge-pr.ts"
TS_FILE_REL = ".opencode/tools/merge-pr.ts"
MERGE_OK_RESPONSE = {"status": 0, "stdout": "", "stderr": ""}
def _run_loader(*args: str) -> dict:
"""Invoke the loader with TS_FILE env set to merge-pr.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: merge-pr.ts loads and declares pr_number + optional repo args."""
if not TS_FILE.exists():
pytest.skip("merge-pr.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 "repo" in args, f"missing repo arg: {args}"
def test_valid_merge():
"""execute() with valid pr_number returns the squash-merged success message."""
out = _run_exec({"pr_number": 42}, [MERGE_OK_RESPONSE])
result = out["result"]
assert result == "PR #42 merged successfully (squash, branch deleted).", (
f"expected success, got: {result!r}"
)
def test_spawnsync_args():
"""spawnSync called with gh pr merge <N> --squash --delete-branch (no --repo).
When repo is omitted, runGh returns [] from parseRepo, so gh auto-detects
the repo from context.worktree (cwd) — matching create-pr.ts and the
pre-refactor behaviour (ADR-025 / PR#61).
"""
out = _run_exec({"pr_number": 42}, [MERGE_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] == "merge", f"expected second arg 'merge', got: {args[1]!r}"
assert args[2] == "42", f"expected PR number '42', got: {args[2]!r}"
assert "--squash" in args, "missing --squash flag"
assert "--delete-branch" in args, "missing --delete-branch flag"
# --repo MUST NOT be present — gh auto-detects from cwd (context.worktree).
assert "--repo" not in args, (
f"--repo must not be hardcoded; gh auto-detects from cwd. args: {args!r}"
)
def test_execute_uses_cwd_from_context():
"""execute passes cwd=context.worktree to spawnSync (ADR-023 pattern)."""
out = _run_exec({"pr_number": 42}, [MERGE_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: PR not mergeable"}
out = _run_exec({"pr_number": 42}, [fail_response])
result = out["result"]
assert "merge_pr failed" in result, f"expected failure message, got: {result!r}"
assert "PR #42" in result, f"expected PR number in failure, got: {result!r}"
assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}"
def test_repo_explicit_passed_to_gh():
"""execute() with repo='foo/bar' → gh receives '--repo foo/bar' before the subcommand.
The shared runGh helper prepends ['--repo', <repo>] to the gh argv so an
explicit repo targets the right owner/name regardless of context.worktree.
"""
out = _run_exec({"pr_number": 42, "repo": "foo/bar"}, [MERGE_OK_RESPONSE])
result = out["result"]
assert result == "PR #42 merged successfully (squash, branch deleted).", (
f"expected success, got: {result!r}"
)
calls = out["calls"]
assert len(calls) == 1, f"expected 1 spawnSync call, got: {len(calls)}"
args = calls[0]["args"]
assert args[0] == "--repo", f"expected --repo first, got: {args[0]!r}"
assert args[1] == "foo/bar", f"expected repo value, got: {args[1]!r}"
assert args[2] == "pr", f"expected 'pr' after --repo <name>, got: {args[2]!r}"
# The squash/delete-branch flags must still be present after the repo prefix.
assert "--squash" in args, "missing --squash flag with explicit repo"
assert "--delete-branch" in args, "missing --delete-branch flag with explicit repo"
def test_repo_omitted_no_repo_flag():
"""execute() without repo → gh argv has NO --repo (auto-detect from cwd).
Backward-compatibility: when repo is omitted, runGh returns [] from
parseRepo, so gh auto-detects the repo from context.worktree (cwd) —
matching the pre-refactor behaviour (ADR-025 / PR#61). This complements
test_spawnsync_args which asserts the same.
"""
out = _run_exec({"pr_number": 42}, [MERGE_OK_RESPONSE])
calls = out["calls"]
assert len(calls) == 1, f"expected 1 spawnSync call, got: {len(calls)}"
args = calls[0]["args"]
assert "--repo" not in args, (
f"--repo must NOT be added when repo arg omitted; gh auto-detects. args: {args!r}"
)
assert args[0] == "pr", f"expected 'pr' first, got: {args[0]!r}"
def test_repo_invalid_gh_error():
"""execute() with invalid repo + gh failure → error mentions merge_pr + exit code.
gh rejects an invalid owner/name with non-zero exit; the tool formats the
error with its tool-specific message (merge_pr failed for PR #N).
"""
fail_response = {"status": 1, "stdout": "", "stderr": 'expected the "owner/repo" format'}
out = _run_exec({"pr_number": 42, "repo": "not-a-valid-repo"}, [fail_response])
result = out["result"]
assert "merge_pr failed" in result, f"expected tool failure, got: {result!r}"
assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}"
calls = out["calls"]
args = calls[0]["args"]
assert "--repo" in args, f"expected --repo in args even on failure, got: {args!r}"