diff --git a/tests/test_write_tools.py b/tests/test_write_tools.py new file mode 100644 index 0000000..50deaca --- /dev/null +++ b/tests/test_write_tools.py @@ -0,0 +1,73 @@ +"""github_create_issue — the first built-out write tool. + +These exercise the tool's command construction and error handling WITHOUT touching +GitHub: ``run_gh`` is patched in the ``ghplugin.write_tools`` namespace (where the tool +looks it up), so we assert the exact ``gh issue create`` argv and the readable error +strings. asyncio_mode=auto (see pyproject) runs the async tests directly. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from ghplugin.write_tools import get_write_tools + + +def _create_issue(): + return {t.name: t for t in get_write_tools()}["github_create_issue"] + + +def _labels_in(args: list[str]) -> list[str]: + """The value following each ``--label`` flag, in order.""" + return [args[i + 1] for i, a in enumerate(args) if a == "--label"] + + +async def test_returns_issue_url(): + """On success the tool returns the new issue URL (gh's stdout, stripped).""" + tool = _create_issue() + fake = AsyncMock(return_value=(0, "https://github.com/o/n/issues/42\n", "")) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "title": "Bug", "body": "It broke"}) + assert out == "https://github.com/o/n/issues/42" + args = fake.call_args.args[0] + assert args[:4] == ["issue", "create", "--repo", "o/n"] + assert "--title" in args and args[args.index("--title") + 1] == "Bug" + assert "--body" in args and args[args.index("--body") + 1] == "It broke" + + +async def test_no_labels_omits_label_flag(): + """No labels → no --label flags at all.""" + tool = _create_issue() + fake = AsyncMock(return_value=(0, "https://github.com/o/n/issues/1", "")) + with patch("ghplugin.write_tools.run_gh", fake): + await tool.ainvoke({"repo": "o/n", "title": "t"}) + assert "--label" not in fake.call_args.args[0] + + +async def test_labels_split_into_separate_flags(): + """Comma-separated labels become one --label flag each, stripped; blanks dropped.""" + tool = _create_issue() + fake = AsyncMock(return_value=(0, "https://github.com/o/n/issues/7", "")) + with patch("ghplugin.write_tools.run_gh", fake): + await tool.ainvoke({"repo": "o/n", "title": "t", "labels": "bug, enhancement ,"}) + assert _labels_in(fake.call_args.args[0]) == ["bug", "enhancement"] + + +async def test_bad_repo_short_circuits(): + """An invalid repo returns the bad_repo error and never shells out.""" + tool = _create_issue() + fake = AsyncMock() + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "not-a-repo", "title": "t"}) + assert out.startswith("Error:") + assert "owner/name" in out + fake.assert_not_called() + + +async def test_gh_failure_returns_check_gh_error(): + """A nonzero gh exit becomes a readable Error string (via check_gh_error).""" + tool = _create_issue() + fake = AsyncMock(return_value=(1, "", "could not create issue: forbidden")) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "title": "t"}) + assert out == "Error (gh exit 1): could not create issue: forbidden" diff --git a/write_tools.py b/write_tools.py index 52e8fab..6125a06 100644 --- a/write_tools.py +++ b/write_tools.py @@ -22,7 +22,7 @@ from langchain_core.tools import tool -from .gh_cli import bad_repo +from .gh_cli import bad_repo, check_gh_error, run_gh def get_write_tools() -> list: @@ -36,11 +36,19 @@ async def github_create_issue(repo: str, title: str, body: str = "", labels: str body: Issue body (Markdown). labels: Optional comma-separated label names. - TODO(team): implement via `gh issue create`. Return the new issue URL. + Returns the new issue URL. """ if err := bad_repo(repo): return err - return "Error: github_create_issue is not implemented yet (stub — to be built by the team)." + args = ["issue", "create", "--repo", repo, "--title", title, "--body", body] + for label in labels.split(","): + label = label.strip() + if label: + args += ["--label", label] + rc, out, serr = await run_gh(args) + if gh_err := check_gh_error(rc, serr): + return gh_err + return out.strip() @tool async def github_comment(repo: str, number: int, body: str) -> str: