diff --git a/.github/workflows/cowork-auto-pr.yml b/.github/workflows/cowork-auto-pr.yml new file mode 100755 index 0000000..91690e6 --- /dev/null +++ b/.github/workflows/cowork-auto-pr.yml @@ -0,0 +1,28 @@ +# Seeded by the repo-improver-rotation Cowork job into cowork/improve-* branches. +# Opens a PR automatically when such a branch is pushed (sandbox cannot reach +# the GitHub API directly; this runs server-side with the repo's GITHUB_TOKEN). +name: cowork-auto-pr +on: + push: + branches: ['cowork/improve-**'] +permissions: + contents: read + pull-requests: write +jobs: + ensure-pr: + runs-on: ubuntu-latest + steps: + - name: Open PR for this branch if none exists + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REF_NAME" --state open --json number --jq 'length') + if [ "$existing" = "0" ]; then + gh pr create --repo "$GITHUB_REPOSITORY" \ + --head "$GITHUB_REF_NAME" \ + --title "cowork-bot: automated improvements ($GITHUB_REF_NAME)" \ + --body "Automated improvement PR from the Cowork repo-improver rotation (one coherent senior-dev improvement per run; see individual commit messages). Subsequent runs push additional commits to this PR rather than opening new ones." + else + echo "Open PR already exists for $GITHUB_REF_NAME — nothing to do." + fi diff --git a/src/devforge/cli.py b/src/devforge/cli.py index 6c297ee..3b3f7f5 100644 --- a/src/devforge/cli.py +++ b/src/devforge/cli.py @@ -1,5 +1,6 @@ """DevForge unified CLI entry point.""" +import importlib.util import subprocess import sys import typer @@ -86,7 +87,7 @@ def install( """Install a DevForge tool.""" if tool == "all": targets = list(TOOLS.keys()) - extras = ",".join(TOOLS.keys()) + extras = "all" elif tool in TOOLS: targets = [tool] extras = tool @@ -138,13 +139,17 @@ def show_versions( console.print(f"[dim]{t:8}[/dim] [red]error checking[/red]") +def _is_tool_installed(module_name: str) -> bool: + """Return True if the module (Python package) is importable.""" + return importlib.util.find_spec(module_name) is not None + + # Dynamically add subcommands for each tool def _make_dispatch(tool_name: str): """Create a typer command that dispatches to the underlying tool CLI.""" pkg = TOOLS[tool_name]["package"] def dispatch( - ctx: typer.Context, args: list[str] = typer.Argument(None, help="Arguments to pass to the tool."), # noqa: B008 ): info = TOOLS.get(tool_name) @@ -152,38 +157,24 @@ def dispatch( console.print(f"[red]Unknown tool: {tool_name}[/red]") raise typer.Exit(code=1) - try: - result = subprocess.run( - [sys.executable, "-m", info["package"].replace("-", "_")] + (args or []), - capture_output=True, - text=True, + module_name = info["package"].replace("-", "_") + if not _is_tool_installed(module_name): + console.print( + f"[red]Tool '{tool_name}' is not installed.[/red]\n" + f"Run: [green]pip install devforge-tools\\[{tool_name}][/green]" ) - if result.returncode == 0: - sys.stdout.write(result.stdout) - if result.stderr: - sys.stderr.write(result.stderr) - sys.exit(0) - # Module not found — show friendly install message - if "No module named" in result.stderr: - console.print( - f"[red]Tool '{tool_name}' not installed.[/red]\n" - f"Install with: [green]pip install devforge[{tool_name}][/green]" - ) - raise typer.Exit(code=1) from None - # Tool ran but failed — show its output and propagate exit code + raise typer.Exit(code=1) + + result = subprocess.run( + [sys.executable, "-m", module_name] + (args or []), + capture_output=True, + text=True, + ) + if result.stdout: sys.stdout.write(result.stdout) + if result.stderr: sys.stderr.write(result.stderr) - sys.exit(result.returncode) - except FileNotFoundError: - # Only reached if sys.executable itself is missing (extremely rare) - console.print( - f"[red]Tool '{tool_name}' not installed.[/red]\n" - f"Install with: [green]pip install devforge-tools[{tool_name}][/green]" - ) - raise typer.Exit(code=1) from None - except Exception as e: - console.print(f"[red]Unexpected error running '{tool_name}': {e}[/red]") - raise typer.Exit(code=1) from e + sys.exit(result.returncode) dispatch.__name__ = tool_name dispatch.__doc__ = f"Run `{pkg}` commands via the {tool_name} subcommand." diff --git a/tests/test_cli.py b/tests/test_cli.py index b5efe82..01fa767 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,7 +3,7 @@ from __future__ import annotations from devforge import TOOLS, __version__ -from devforge.cli import app +from devforge.cli import _is_tool_installed, app from typer.testing import CliRunner from unittest import mock @@ -48,13 +48,17 @@ def test_install_specific_tool(self, mock_run): mock_run.assert_called_once() @mock.patch("devforge.cli.subprocess.run") - def test_install_all(self, mock_run): - """Install all tools via the 'all' alias.""" + def test_install_all_uses_all_extra(self, mock_run): + """'install all' must use the canonical devforge-tools[all] extra, not a comma-joined list.""" mock_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="") result = runner.invoke(app, ["install", "all"]) assert result.exit_code == 0 assert "Successfully" in result.stdout mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] # positional arg: the command list + # Must contain "devforge-tools[all]", not "devforge-tools[guard,sql,...]" + pkg_arg = next((a for a in call_args if a.startswith("devforge-tools[")), None) + assert pkg_arg == "devforge-tools[all]", f"Expected devforge-tools[all], got {pkg_arg}" def test_install_unknown_tool(self): """Error on unknown tool name.""" @@ -94,14 +98,54 @@ def test_versions_specific_tool_not_installed(self, mock_run): assert "not installed" in result.stdout +class TestIsToolInstalled: + def test_builtin_module_is_installed(self): + """stdlib module should always be found.""" + assert _is_tool_installed("sys") is True + + def test_missing_module_is_not_installed(self): + """Nonexistent module should return False.""" + assert _is_tool_installed("_devforge_no_such_pkg_xyz") is False + + class TestDispatchCommands: def test_invalid_tool_subcommand(self): """Reject dispatch to an unknown tool subcommand.""" result = runner.invoke(app, ["nonexistent"]) - # typer outputs error to stderr, not stdout assert result.exit_code != 0 assert "No such command" in result.stdout or "No such command" in result.stderr + @mock.patch("devforge.cli._is_tool_installed", return_value=False) + def test_dispatch_not_installed_shows_install_hint(self, _mock): + """When a tool is not installed, dispatch shows a clear install hint (not a silent exit).""" + result = runner.invoke(app, ["guard"]) + assert result.exit_code == 1 + assert "not installed" in result.stdout + assert "pip install devforge-tools[guard]" in result.stdout + + @mock.patch("devforge.cli._is_tool_installed", return_value=True) + @mock.patch("devforge.cli.subprocess.run") + def test_dispatch_installed_tool_runs(self, mock_run, _mock_installed): + """When a tool is installed, dispatch calls the subprocess.""" + mock_run.return_value = mock.MagicMock(returncode=0) + with mock.patch("devforge.cli.sys.exit"): + runner.invoke(app, ["guard"]) + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "api_contract_guardian" in cmd + + + @mock.patch("devforge.cli._is_tool_installed", return_value=False) + def test_dispatch_install_hint_escapes_extra_brackets(self, _mock): + """The '[tool]' extra in the install hint must survive rich markup parsing. + + A regression guard: an unescaped '[guard]' was previously swallowed by + rich's markup parser, rendering 'pip install devforge' with no extra. + """ + result = runner.invoke(app, ["guard"]) + assert result.exit_code == 1 + assert "pip install devforge-tools[guard]" in result.stdout + class TestHelp: def test_help(self):