Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/cowork-auto-pr.yml
Original file line number Diff line number Diff line change
@@ -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
53 changes: 22 additions & 31 deletions src/devforge/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""DevForge unified CLI entry point."""

import importlib.util
import subprocess
import sys
import typer
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -138,52 +139,42 @@ 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)
if not info:
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."
Expand Down
52 changes: 48 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the extra blank line so formatting passes

In this PR, .github/workflows/ci.yml still runs ruff format --check src/ tests/; running that command on this commit reports Would reformat: tests/test_cli.py and the formatter diff removes this extra blank line. As-is, PR CI will fail in the formatting job before tests run, so please run the formatter or remove the blank line.

Useful? React with 👍 / 👎.

@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):
Expand Down
Loading