From 5f41319983a7b7a2c1a5f0fc57e914622447eceb Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sun, 21 Jun 2026 12:58:18 -0700 Subject: [PATCH] feat: Implement github_create_pr in write_tools.py + test --- tests/test_write_tools.py | 58 +++++++++++++++++++++++++++++++++++++++ write_tools.py | 21 ++++++++++++-- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/tests/test_write_tools.py b/tests/test_write_tools.py index cb1aa1f..4c82c52 100644 --- a/tests/test_write_tools.py +++ b/tests/test_write_tools.py @@ -117,3 +117,61 @@ async def test_comment_gh_failure_returns_check_gh_error(): with patch("ghplugin.write_tools.run_gh", fake): out = await tool.ainvoke({"repo": "o/n", "number": 1, "body": "hi"}) assert out == "Error (gh exit 1): could not comment: forbidden" + + +def _create_pr(): + return {t.name: t for t in get_write_tools()}["github_create_pr"] + + +async def test_create_pr_returns_pr_url(): + """On success the tool returns the new PR URL (gh's stdout, stripped).""" + tool = _create_pr() + fake = AsyncMock(return_value=(0, "https://github.com/o/n/pull/5\n", "")) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "head": "feature", "title": "Add thing", "body": "Does the thing"}) + assert out == "https://github.com/o/n/pull/5" + args = fake.call_args.args[0] + assert args[:4] == ["pr", "create", "--repo", "o/n"] + assert "--head" in args and args[args.index("--head") + 1] == "feature" + assert "--title" in args and args[args.index("--title") + 1] == "Add thing" + assert "--body" in args and args[args.index("--body") + 1] == "Does the thing" + + +async def test_create_pr_base_defaults_to_main(): + """The base flag is always passed and defaults to ``main`` when omitted.""" + tool = _create_pr() + fake = AsyncMock(return_value=(0, "https://github.com/o/n/pull/6", "")) + with patch("ghplugin.write_tools.run_gh", fake): + await tool.ainvoke({"repo": "o/n", "head": "feature", "title": "t"}) + args = fake.call_args.args[0] + assert "--base" in args and args[args.index("--base") + 1] == "main" + + +async def test_create_pr_base_override_is_passed(): + """An explicit base branch is passed through to gh.""" + tool = _create_pr() + fake = AsyncMock(return_value=(0, "https://github.com/o/n/pull/7", "")) + with patch("ghplugin.write_tools.run_gh", fake): + await tool.ainvoke({"repo": "o/n", "head": "feature", "title": "t", "base": "develop"}) + args = fake.call_args.args[0] + assert "--base" in args and args[args.index("--base") + 1] == "develop" + + +async def test_create_pr_bad_repo_short_circuits(): + """An invalid repo returns the bad_repo error and never shells out.""" + tool = _create_pr() + fake = AsyncMock() + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "not-a-repo", "head": "feature", "title": "t"}) + assert out.startswith("Error:") + assert "owner/name" in out + fake.assert_not_called() + + +async def test_create_pr_gh_failure_returns_check_gh_error(): + """A nonzero gh exit becomes a readable Error string (via check_gh_error).""" + tool = _create_pr() + fake = AsyncMock(return_value=(1, "", "could not create pr: forbidden")) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "head": "feature", "title": "t"}) + assert out == "Error (gh exit 1): could not create pr: forbidden" diff --git a/write_tools.py b/write_tools.py index 9e32eb6..80e3149 100644 --- a/write_tools.py +++ b/write_tools.py @@ -80,10 +80,27 @@ async def github_create_pr(repo: str, head: str, title: str, body: str = "", bas body: PR body (Markdown). base: Base branch to merge into (default ``main``). - TODO(team): implement via `gh pr create`. Return the new PR URL. + Returns the new PR URL. """ if err := bad_repo(repo): return err - return "Error: github_create_pr is not implemented yet (stub — to be built by the team)." + args = [ + "pr", + "create", + "--repo", + repo, + "--head", + head, + "--base", + base, + "--title", + title, + "--body", + body, + ] + rc, out, serr = await run_gh(args) + if gh_err := check_gh_error(rc, serr): + return gh_err + return out.strip() return [github_create_issue, github_comment, github_create_pr]