From 5aa3fe4dc5453d22c00c55e2541b5baf19323006 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 28 Jan 2026 08:50:10 -0700 Subject: [PATCH 1/4] feat(cli): add comprehensive GitHub PR workflow commands Add `codeframe pr` command group for GitHub Pull Request management: - `pr create`: Create PRs with auto-generated descriptions from commits - `pr list`: List PRs with status filtering and JSON output - `pr get`: Get detailed PR information - `pr merge`: Merge PRs with squash/merge/rebase strategies - `pr close`: Close PRs without merging - `pr status`: Show PR status for current branch Implementation follows v2 CLI patterns (headless, no server required): - Uses existing GitHubIntegration async client - Wraps async calls with asyncio.run() for CLI context - Full test coverage (23 tests) using TDD approach - Environment-based configuration (GITHUB_TOKEN, GITHUB_REPO) Commands registered in both v1 CLI (__init__.py) and v2 CLI (app.py). Documentation updated in CLI_WIREFRAME.md with examples. --- codeframe/cli/__init__.py | 2 + codeframe/cli/app.py | 2 + codeframe/cli/pr_commands.py | 545 +++++++++++++++++++++++++++++++ docs/CLI_WIREFRAME.md | 116 +++++-- tests/cli/test_pr_commands.py | 590 ++++++++++++++++++++++++++++++++++ 5 files changed, 1229 insertions(+), 26 deletions(-) create mode 100644 codeframe/cli/pr_commands.py create mode 100644 tests/cli/test_pr_commands.py diff --git a/codeframe/cli/__init__.py b/codeframe/cli/__init__.py index ce09a1df..f9d01a71 100644 --- a/codeframe/cli/__init__.py +++ b/codeframe/cli/__init__.py @@ -316,6 +316,7 @@ def version(): from codeframe.cli.session_commands import session_app # noqa: E402 from codeframe.cli.context_commands import context_app # noqa: E402 from codeframe.cli.review_commands import review_app # noqa: E402 +from codeframe.cli.pr_commands import pr_app # noqa: E402 # Register Phase 1 command groups app.add_typer(auth_app, name="auth", help="Authentication (login, logout, register)") @@ -332,6 +333,7 @@ def version(): app.add_typer(session_app, name="session", help="Session management (get)") app.add_typer(context_app, name="context", help="Agent context (get, stats, flash-save, checkpoints)") app.add_typer(review_app, name="review", help="Code review management (status, stats, findings, list)") +app.add_typer(pr_app, name="pr", help="Pull request management (create, list, merge, close)") if __name__ == "__main__": diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index 6d2c9c91..4bcd379c 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -23,6 +23,7 @@ # Import auth subapp for credential management from codeframe.cli.auth_commands import auth_app +from codeframe.cli.pr_commands import pr_app # Load environment variables from .env files # Priority: workspace .env > home .env @@ -4160,6 +4161,7 @@ def templates_apply( app.add_typer(schedule_app, name="schedule") app.add_typer(templates_app, name="templates") app.add_typer(auth_app, name="auth") +app.add_typer(pr_app, name="pr") # ============================================================================= diff --git a/codeframe/cli/pr_commands.py b/codeframe/cli/pr_commands.py new file mode 100644 index 00000000..1af0881e --- /dev/null +++ b/codeframe/cli/pr_commands.py @@ -0,0 +1,545 @@ +"""CLI PR commands for GitHub Pull Request management. + +This module provides commands for PR operations: +- create: Create a new PR from current or specified branch +- list: List PRs with optional status filter +- get: Get PR details by number +- merge: Merge a PR with specified strategy +- close: Close a PR without merging +- status: Show PR status for current branch + +Usage: + codeframe pr create --title "My feature" + codeframe pr list --status open + codeframe pr get 42 + codeframe pr merge 42 --strategy squash + codeframe pr close 42 + codeframe pr status +""" + +import asyncio +import json +import logging +import os +import subprocess +from dataclasses import asdict +from pathlib import Path +from typing import Optional + +import typer +from rich.table import Table + +from codeframe.cli.helpers import console +from codeframe.git.github_integration import GitHubAPIError, GitHubIntegration + +logger = logging.getLogger(__name__) + + +pr_app = typer.Typer( + name="pr", + help="Pull request management (create, list, merge, close)", + no_args_is_help=True, +) + + +def get_current_branch(repo_path: Optional[Path] = None) -> str: + """Get the current git branch name. + + Args: + repo_path: Path to the git repository (defaults to cwd) + + Returns: + Current branch name + + Raises: + RuntimeError: If not in a git repository + """ + cwd = repo_path or Path.cwd() + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to get current branch: {e.stderr}") + + +def get_git_diff_stats(repo_path: Path, base: str, head: str) -> str: + """Get diff statistics between two branches. + + Args: + repo_path: Path to the git repository + base: Base branch name + head: Head branch name + + Returns: + Diff statistics as string + """ + try: + result = subprocess.run( + ["git", "diff", "--stat", f"{base}...{head}"], + cwd=repo_path, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + return "" + + +def get_commit_messages(repo_path: Path, base: str, head: str) -> str: + """Get commit messages between two branches. + + Args: + repo_path: Path to the git repository + base: Base branch name + head: Head branch name + + Returns: + Commit messages as string + """ + try: + result = subprocess.run( + ["git", "log", "--oneline", f"{base}..{head}"], + cwd=repo_path, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + return "" + + +def _get_github_config() -> tuple[str, str]: + """Get GitHub token and repo from environment. + + Returns: + Tuple of (token, repo) + + Raises: + typer.Exit: If configuration is missing + """ + token = os.environ.get("GITHUB_TOKEN") + repo = os.environ.get("GITHUB_REPO") + + if not token: + console.print("[red]Error:[/red] GITHUB_TOKEN environment variable not set.") + console.print("Set it with: export GITHUB_TOKEN=ghp_yourtoken") + raise typer.Exit(1) + + if not repo: + console.print("[red]Error:[/red] GITHUB_REPO environment variable not set.") + console.print("Set it with: export GITHUB_REPO=owner/repo") + raise typer.Exit(1) + + return token, repo + + +def _run_async(coro): + """Run an async coroutine in a sync context.""" + return asyncio.run(coro) + + +@pr_app.command("create") +def create_pr( + branch: Optional[str] = typer.Option( + None, "--branch", "-b", help="Branch name (defaults to current)" + ), + title: Optional[str] = typer.Option( + None, "--title", "-t", help="PR title" + ), + body: Optional[str] = typer.Option( + None, "--body", help="PR description body" + ), + base: str = typer.Option( + "main", "--base", help="Base branch to merge into" + ), + auto_description: bool = typer.Option( + True, + "--auto-description/--no-auto-description", + help="Auto-generate PR description from commits", + ), +): + """Create a new pull request. + + Creates a PR from the current branch (or specified branch) to the base branch. + Optionally auto-generates a description from commit messages. + + Examples: + + codeframe pr create --title "Add new feature" + + codeframe pr create --branch feature/auth --title "Auth system" --base develop + + codeframe pr create --title "Quick fix" --no-auto-description + """ + try: + token, repo = _get_github_config() + + # Get branch name + if not branch: + try: + branch = get_current_branch() + except RuntimeError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + # Validate not on base branch + if branch == base: + console.print(f"[red]Error:[/red] Cannot create PR from '{branch}' to itself.") + console.print("Please checkout a feature branch first.") + raise typer.Exit(1) + + # Generate body if auto-description and no body provided + if auto_description and not body: + repo_path = Path.cwd() + commits = get_commit_messages(repo_path, base, branch) + diff_stats = get_git_diff_stats(repo_path, base, branch) + + body = f"## Changes\n\n{commits}\n\n## Files Changed\n\n```\n{diff_stats}\n```" + + if not body: + body = "" + + # Title is required + if not title: + console.print("[red]Error:[/red] --title is required.") + raise typer.Exit(1) + + async def _create(): + gh = GitHubIntegration(token=token, repo=repo) + try: + pr = await gh.create_pull_request( + branch=branch, + title=title, + body=body, + base=base, + ) + return pr + finally: + await gh.close() + + pr = _run_async(_create()) + + console.print(f"[green]✓ PR #{pr.number} created successfully[/green]") + console.print(f"\n[bold]Title:[/bold] {pr.title}") + console.print(f"[bold]Branch:[/bold] {pr.head_branch} → {pr.base_branch}") + console.print(f"[bold]URL:[/bold] [link={pr.url}]{pr.url}[/link]") + + except GitHubAPIError as e: + if e.status_code == 422: + console.print("[red]Error:[/red] PR already exists for this branch or validation failed.") + if e.details: + console.print(f"Details: {e.details}") + else: + console.print(f"[red]GitHub API Error ({e.status_code}):[/red] {e.message}") + raise typer.Exit(1) + + +@pr_app.command("list") +def list_prs( + status: str = typer.Option( + "open", "--status", "-s", help="Filter by status: open, closed, all" + ), + format: str = typer.Option( + "table", "--format", "-f", help="Output format: table or json" + ), +): + """List pull requests. + + Shows PRs with optional filtering by status. + + Examples: + + codeframe pr list + + codeframe pr list --status closed + + codeframe pr list --format json + """ + try: + token, repo = _get_github_config() + + async def _list(): + gh = GitHubIntegration(token=token, repo=repo) + try: + prs = await gh.list_pull_requests(state=status) + return prs + finally: + await gh.close() + + prs = _run_async(_list()) + + if format == "json": + # Convert PRDetails to dicts for JSON output + pr_dicts = [] + for pr in prs: + d = asdict(pr) + # Convert datetime to ISO string + d["created_at"] = pr.created_at.isoformat() if pr.created_at else None + d["merged_at"] = pr.merged_at.isoformat() if pr.merged_at else None + pr_dicts.append(d) + console.print(json.dumps(pr_dicts, indent=2)) + return + + # Table format + if not prs: + console.print(f"[yellow]No {status} pull requests found.[/yellow]") + return + + table = Table(title=f"Pull Requests ({status})") + table.add_column("PR #", style="cyan", no_wrap=True) + table.add_column("Title", max_width=40) + table.add_column("Branch", style="blue") + table.add_column("State", style="yellow") + table.add_column("Created") + + for pr in prs: + # Format state with color + state_display = pr.state + if pr.state == "open": + state_display = f"[green]{pr.state}[/green]" + elif pr.merged_at: + state_display = "[magenta]merged[/magenta]" + elif pr.state == "closed": + state_display = f"[red]{pr.state}[/red]" + + table.add_row( + str(pr.number), + pr.title[:40] if pr.title else "", + pr.head_branch, + state_display, + pr.created_at.strftime("%Y-%m-%d") if pr.created_at else "", + ) + + console.print(table) + + except GitHubAPIError as e: + if "rate limit" in e.message.lower(): + console.print("[red]Error:[/red] GitHub API rate limit exceeded.") + console.print("Please wait and try again later.") + else: + console.print(f"[red]GitHub API Error ({e.status_code}):[/red] {e.message}") + raise typer.Exit(1) + + +@pr_app.command("get") +def get_pr( + pr_number: int = typer.Argument(..., help="PR number"), + format: str = typer.Option( + "text", "--format", "-f", help="Output format: text or json" + ), +): + """Get pull request details. + + Shows full information about a specific PR. + + Example: + + codeframe pr get 42 + """ + try: + token, repo = _get_github_config() + + async def _get(): + gh = GitHubIntegration(token=token, repo=repo) + try: + pr = await gh.get_pull_request(pr_number) + return pr + finally: + await gh.close() + + pr = _run_async(_get()) + + if format == "json": + d = asdict(pr) + d["created_at"] = pr.created_at.isoformat() if pr.created_at else None + d["merged_at"] = pr.merged_at.isoformat() if pr.merged_at else None + console.print(json.dumps(d, indent=2)) + return + + # Text format + console.print(f"\n[bold]PR #{pr.number}[/bold] - {pr.title}") + console.print(f"\n[bold]State:[/bold] {pr.state}") + console.print(f"[bold]Branch:[/bold] {pr.head_branch} → {pr.base_branch}") + console.print(f"[bold]Created:[/bold] {pr.created_at.strftime('%Y-%m-%d %H:%M') if pr.created_at else 'N/A'}") + + if pr.merged_at: + console.print(f"[bold]Merged:[/bold] {pr.merged_at.strftime('%Y-%m-%d %H:%M')}") + + console.print(f"[bold]URL:[/bold] {pr.url}") + + if pr.body: + console.print(f"\n[bold]Description:[/bold]\n{pr.body}") + + except GitHubAPIError as e: + if e.status_code == 404: + console.print(f"[red]Error:[/red] PR #{pr_number} not found") + else: + console.print(f"[red]GitHub API Error ({e.status_code}):[/red] {e.message}") + raise typer.Exit(1) + + +@pr_app.command("merge") +def merge_pr( + pr_number: int = typer.Argument(..., help="PR number to merge"), + strategy: str = typer.Option( + "squash", + "--strategy", + "-s", + help="Merge strategy: squash, merge, rebase", + ), +): + """Merge a pull request. + + Merges the specified PR using the chosen merge strategy. + + Examples: + + codeframe pr merge 42 + + codeframe pr merge 42 --strategy rebase + """ + try: + token, repo = _get_github_config() + + async def _merge(): + gh = GitHubIntegration(token=token, repo=repo) + try: + # First check PR state + pr = await gh.get_pull_request(pr_number) + + if pr.merged_at: + console.print(f"[yellow]PR #{pr_number} is already merged.[/yellow]") + return None + + if pr.state != "open": + console.print(f"[yellow]PR #{pr_number} is {pr.state} and cannot be merged.[/yellow]") + return None + + # Merge the PR + result = await gh.merge_pull_request(pr_number, method=strategy) + return result + finally: + await gh.close() + + result = _run_async(_merge()) + + if result is None: + raise typer.Exit(0) + + if result.merged: + console.print(f"[green]✓ PR #{pr_number} merged successfully[/green]") + if result.sha: + console.print(f"[bold]Merge commit:[/bold] {result.sha[:7]}") + console.print(f"[bold]Strategy:[/bold] {strategy}") + else: + console.print(f"[red]Error:[/red] Merge failed: {result.message}") + raise typer.Exit(1) + + except GitHubAPIError as e: + if e.status_code == 404: + console.print(f"[red]Error:[/red] PR #{pr_number} not found") + elif e.status_code == 405: + console.print("[red]Error:[/red] PR cannot be merged (check for conflicts)") + else: + console.print(f"[red]GitHub API Error ({e.status_code}):[/red] {e.message}") + raise typer.Exit(1) + + +@pr_app.command("close") +def close_pr( + pr_number: int = typer.Argument(..., help="PR number to close"), +): + """Close a pull request without merging. + + Example: + + codeframe pr close 42 + """ + try: + token, repo = _get_github_config() + + async def _close(): + gh = GitHubIntegration(token=token, repo=repo) + try: + # First check PR exists + pr = await gh.get_pull_request(pr_number) + + if pr.state == "closed": + console.print(f"[yellow]PR #{pr_number} is already closed.[/yellow]") + return True + + # Close the PR + result = await gh.close_pull_request(pr_number) + return result + finally: + await gh.close() + + result = _run_async(_close()) + + if result: + console.print(f"[green]✓ PR #{pr_number} closed[/green]") + else: + console.print(f"[red]Error:[/red] Failed to close PR #{pr_number}") + raise typer.Exit(1) + + except GitHubAPIError as e: + if e.status_code == 404: + console.print(f"[red]Error:[/red] PR #{pr_number} not found") + else: + console.print(f"[red]GitHub API Error ({e.status_code}):[/red] {e.message}") + raise typer.Exit(1) + + +@pr_app.command("status") +def pr_status(): + """Show PR status for current branch. + + Checks if there's an open PR for the current branch and displays its status. + + Example: + + codeframe pr status + """ + try: + token, repo = _get_github_config() + + try: + current_branch = get_current_branch() + except RuntimeError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + async def _status(): + gh = GitHubIntegration(token=token, repo=repo) + try: + prs = await gh.list_pull_requests(state="open") + # Find PR for current branch + for pr in prs: + if pr.head_branch == current_branch: + return pr + return None + finally: + await gh.close() + + pr = _run_async(_status()) + + if pr: + console.print(f"\n[bold]PR #{pr.number}[/bold] - {pr.title}") + console.print(f"[bold]State:[/bold] [green]{pr.state}[/green]") + console.print(f"[bold]Branch:[/bold] {pr.head_branch} → {pr.base_branch}") + console.print(f"[bold]URL:[/bold] {pr.url}") + else: + console.print(f"[yellow]No open PR found for branch '{current_branch}'[/yellow]") + console.print("\nCreate one with: codeframe pr create --title \"Your PR title\"") + + except GitHubAPIError as e: + console.print(f"[red]GitHub API Error ({e.status_code}):[/red] {e.message}") + raise typer.Exit(1) diff --git a/docs/CLI_WIREFRAME.md b/docs/CLI_WIREFRAME.md index c2f265cb..b0f547da 100644 --- a/docs/CLI_WIREFRAME.md +++ b/docs/CLI_WIREFRAME.md @@ -650,54 +650,118 @@ cf work batch resume abc123 --force # Re-run all tasks --- -### `codeframe pr create` (Enhanced) -**Purpose:** Create pull request with AI-generated description. +### `codeframe pr create [--title ] [--branch <branch>] [--base <base>]` +**Purpose:** Create pull request with optional auto-generated description from commits. **CLI module:** -- `codeframe/cli/commands/pr.py` +- `codeframe/cli/pr_commands.py` **Core calls:** -- `pr = codeframe.core.git.create_pull_request(workspace_id, branch_name, base_branch)` -- `description = codeframe.core.prd.generate_pr_description(workspace_id, task_ids)` -- `codeframe.core.git.update_pr_description(pr.id, description)` -- `emit(..., "PR_CREATED", payload)` +- `GitHubIntegration.create_pull_request(branch, title, body, base)` -**State writes:** -- PR record with task associations -- Transition of associated tasks to IN_REVIEW status +**Options:** +- `--title/-t`: PR title (required) +- `--branch/-b`: Source branch (defaults to current) +- `--base`: Target branch (defaults to main) +- `--body`: PR description body +- `--auto-description/--no-auto-description`: Auto-generate from commits **Adapter usage:** -- git adapter for PR operations -- LLM adapter for description generation +- `codeframe.git.github_integration.GitHubIntegration` + +**Examples:** +```bash +codeframe pr create --title "Add new feature" +codeframe pr create --branch feature/auth --title "Auth system" --base develop +codeframe pr create --title "Quick fix" --no-auto-description +``` --- -### `codeframe pr list [--status open|closed|merged]` -**Purpose:** List pull requests and their status. +### `codeframe pr list [--status open|closed|all] [--format table|json]` +**Purpose:** List pull requests with optional filtering. **CLI module:** -- `codeframe/cli/commands/pr.py` +- `codeframe/cli/pr_commands.py` **Core calls:** -- `prs = codeframe.core.git.list_pull_requests(workspace_id, status_filter)` +- `GitHubIntegration.list_pull_requests(state)` + +**Examples:** +```bash +codeframe pr list +codeframe pr list --status closed +codeframe pr list --format json +``` --- -### `codeframe pr merge <pr-id> [--strategy squash|merge|rebase]` -**Purpose:** Merge pull request with verification. +### `codeframe pr get <pr-number> [--format text|json]` +**Purpose:** Get detailed information about a specific PR. **CLI module:** -- `codeframe/cli/commands/pr.py` +- `codeframe/cli/pr_commands.py` **Core calls:** -- `codeframe.core.gates.run_pr_checks(workspace_id, pr_id)` -- `merge_result = codeframe.core.git.merge_pull_request(pr_id, strategy)` -- `codeframe.core.tasks.transition_to_merged(workspace_id, pr.task_ids)` -- `emit(..., "PR_MERGED", payload)` +- `GitHubIntegration.get_pull_request(pr_number)` -**State writes:** -- Transition of associated tasks to MERGED status -- PR record with merge details +**Examples:** +```bash +codeframe pr get 42 +codeframe pr get 42 --format json +``` + +--- + +### `codeframe pr merge <pr-number> [--strategy squash|merge|rebase]` +**Purpose:** Merge pull request with specified strategy. + +**CLI module:** +- `codeframe/cli/pr_commands.py` + +**Core calls:** +- `GitHubIntegration.get_pull_request(pr_number)` (validate state) +- `GitHubIntegration.merge_pull_request(pr_number, method)` + +**Examples:** +```bash +codeframe pr merge 42 +codeframe pr merge 42 --strategy rebase +``` + +--- + +### `codeframe pr close <pr-number>` +**Purpose:** Close a pull request without merging. + +**CLI module:** +- `codeframe/cli/pr_commands.py` + +**Core calls:** +- `GitHubIntegration.close_pull_request(pr_number)` + +**Examples:** +```bash +codeframe pr close 42 +``` + +--- + +### `codeframe pr status` +**Purpose:** Show PR status for current branch. + +**CLI module:** +- `codeframe/cli/pr_commands.py` + +**Core calls:** +- `get_current_branch()` (git helper) +- `GitHubIntegration.list_pull_requests(state="open")` +- Filters to find PR matching current branch + +**Examples:** +```bash +codeframe pr status +``` --- diff --git a/tests/cli/test_pr_commands.py b/tests/cli/test_pr_commands.py new file mode 100644 index 00000000..bac33d7e --- /dev/null +++ b/tests/cli/test_pr_commands.py @@ -0,0 +1,590 @@ +"""Tests for CLI PR commands. + +TDD approach: Write tests first, then implement. +These tests cover the `codeframe pr` command group for GitHub PR management. +""" + +import json +from datetime import datetime, UTC +from unittest.mock import AsyncMock, patch + +import pytest +from typer.testing import CliRunner + +# Mark all tests as v2 +pytestmark = pytest.mark.v2 + + +runner = CliRunner() + + +@pytest.fixture +def mock_github_token(monkeypatch): + """Set up mock GitHub token for tests.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test_token_12345") + monkeypatch.setenv("GITHUB_REPO", "testowner/testrepo") + + +@pytest.fixture +def mock_pr_details(): + """Mock PRDetails response from GitHub API.""" + from codeframe.git.github_integration import PRDetails + + return PRDetails( + number=42, + url="https://github.com/testowner/testrepo/pull/42", + state="open", + title="Add new feature", + body="This PR adds a new feature.\n\n## Summary\n- Added feature X", + created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC), + merged_at=None, + head_branch="feature/new-feature", + base_branch="main", + ) + + +class TestPRCreateCommand: + """Tests for 'codeframe pr create' command.""" + + def test_create_pr_success(self, mock_github_token, mock_pr_details, tmp_path): + """Create PR should call GitHub API and display success.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.create_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + with patch( + "codeframe.cli.pr_commands.get_current_branch", + return_value="feature/new-feature", + ): + result = runner.invoke( + pr_app, + ["create", "--title", "Add new feature", "--no-auto-description"], + ) + + assert result.exit_code == 0 + assert "#42" in result.output or "42" in result.output + assert "github.com" in result.output.lower() or "created" in result.output.lower() + + def test_create_pr_with_explicit_branch(self, mock_github_token, mock_pr_details): + """Create PR with explicit branch name.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.create_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke( + pr_app, + [ + "create", + "--branch", "feature/explicit-branch", + "--title", "My PR", + "--base", "develop", + "--no-auto-description", + ], + ) + + assert result.exit_code == 0 + mock_gh.create_pull_request.assert_called_once() + call_args = mock_gh.create_pull_request.call_args + assert call_args.kwargs.get("base") == "develop" or call_args[1].get("base") == "develop" + + def test_create_pr_no_github_token_shows_error(self, monkeypatch): + """Create PR without GitHub token should show helpful error.""" + from codeframe.cli.pr_commands import pr_app + + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_REPO", raising=False) + + result = runner.invoke( + pr_app, + ["create", "--title", "Test", "--no-auto-description"], + ) + + assert result.exit_code != 0 + assert "github" in result.output.lower() or "token" in result.output.lower() + + def test_create_pr_with_body(self, mock_github_token, mock_pr_details): + """Create PR with explicit body content.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.create_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + with patch( + "codeframe.cli.pr_commands.get_current_branch", + return_value="feature/new-feature", + ): + result = runner.invoke( + pr_app, + [ + "create", + "--title", "My Feature", + "--body", "This is the PR description", + "--no-auto-description", + ], + ) + + assert result.exit_code == 0 + call_args = mock_gh.create_pull_request.call_args + assert "This is the PR description" in str(call_args) + + +class TestPRListCommand: + """Tests for 'codeframe pr list' command.""" + + def test_list_prs_success(self, mock_github_token, mock_pr_details): + """List PRs should display table of PRs.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[mock_pr_details]) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["list"]) + + assert result.exit_code == 0 + assert "42" in result.output + assert "Add new feature" in result.output or "new-feature" in result.output.lower() + + def test_list_prs_with_status_filter(self, mock_github_token, mock_pr_details): + """List PRs with status filter.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[mock_pr_details]) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["list", "--status", "closed"]) + + assert result.exit_code == 0 + mock_gh.list_pull_requests.assert_called_once_with(state="closed") + + def test_list_prs_json_format(self, mock_github_token, mock_pr_details): + """List PRs with JSON output format.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[mock_pr_details]) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["list", "--format", "json"]) + + assert result.exit_code == 0 + # Should contain valid JSON + try: + output_json = json.loads(result.output) + assert isinstance(output_json, list) + except json.JSONDecodeError: + pytest.fail("Output should be valid JSON") + + def test_list_prs_empty(self, mock_github_token): + """List PRs when none exist.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[]) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["list"]) + + assert result.exit_code == 0 + assert "no" in result.output.lower() or "empty" in result.output.lower() or "0" in result.output + + +class TestPRGetCommand: + """Tests for 'codeframe pr get' command.""" + + def test_get_pr_success(self, mock_github_token, mock_pr_details): + """Get PR should display full PR details.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["get", "42"]) + + assert result.exit_code == 0 + assert "42" in result.output + assert "Add new feature" in result.output + assert "open" in result.output.lower() + + def test_get_pr_not_found(self, mock_github_token): + """Get PR with invalid number should show error.""" + from codeframe.cli.pr_commands import pr_app + from codeframe.git.github_integration import GitHubAPIError + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock( + side_effect=GitHubAPIError(404, "Not Found") + ) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["get", "9999"]) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() or "404" in result.output + + def test_get_pr_json_format(self, mock_github_token, mock_pr_details): + """Get PR with JSON output format.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["get", "42", "--format", "json"]) + + assert result.exit_code == 0 + try: + output_json = json.loads(result.output) + assert output_json["number"] == 42 + except json.JSONDecodeError: + pytest.fail("Output should be valid JSON") + + +class TestPRMergeCommand: + """Tests for 'codeframe pr merge' command.""" + + def test_merge_pr_success(self, mock_github_token, mock_pr_details): + """Merge PR should call GitHub API.""" + from codeframe.cli.pr_commands import pr_app + from codeframe.git.github_integration import MergeResult + + merge_result = MergeResult( + sha="abc123def456", + merged=True, + message="Pull Request successfully merged", + ) + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.merge_pull_request = AsyncMock(return_value=merge_result) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["merge", "42"]) + + assert result.exit_code == 0 + assert "merged" in result.output.lower() + mock_gh.merge_pull_request.assert_called_once() + + def test_merge_pr_with_strategy(self, mock_github_token, mock_pr_details): + """Merge PR with specific strategy.""" + from codeframe.cli.pr_commands import pr_app + from codeframe.git.github_integration import MergeResult + + merge_result = MergeResult( + sha="abc123def456", + merged=True, + message="PR merged via rebase", + ) + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.merge_pull_request = AsyncMock(return_value=merge_result) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["merge", "42", "--strategy", "rebase"]) + + assert result.exit_code == 0 + mock_gh.merge_pull_request.assert_called_once_with(42, method="rebase") + + def test_merge_pr_not_found(self, mock_github_token): + """Merge PR that doesn't exist shows error.""" + from codeframe.cli.pr_commands import pr_app + from codeframe.git.github_integration import GitHubAPIError + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock( + side_effect=GitHubAPIError(404, "Not Found") + ) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["merge", "9999"]) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() or "error" in result.output.lower() + + def test_merge_pr_already_merged(self, mock_github_token, mock_pr_details): + """Merge PR that's already merged shows appropriate message.""" + from codeframe.cli.pr_commands import pr_app + from codeframe.git.github_integration import PRDetails + + already_merged_pr = PRDetails( + number=42, + url="https://github.com/testowner/testrepo/pull/42", + state="closed", + title="Already merged", + body="", + created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC), + merged_at=datetime(2024, 1, 16, 10, 30, 0, tzinfo=UTC), + head_branch="feature/done", + base_branch="main", + ) + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=already_merged_pr) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["merge", "42"]) + + # Should fail or show message that PR is already merged + assert "merged" in result.output.lower() or "closed" in result.output.lower() + + +class TestPRCloseCommand: + """Tests for 'codeframe pr close' command.""" + + def test_close_pr_success(self, mock_github_token, mock_pr_details): + """Close PR should call GitHub API.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close_pull_request = AsyncMock(return_value=True) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["close", "42"]) + + assert result.exit_code == 0 + assert "closed" in result.output.lower() + mock_gh.close_pull_request.assert_called_once_with(42) + + def test_close_pr_not_found(self, mock_github_token): + """Close PR that doesn't exist shows error.""" + from codeframe.cli.pr_commands import pr_app + from codeframe.git.github_integration import GitHubAPIError + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock( + side_effect=GitHubAPIError(404, "Not Found") + ) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["close", "9999"]) + + assert result.exit_code != 0 + + +class TestPRStatusCommand: + """Tests for 'codeframe pr status' command.""" + + def test_status_shows_current_branch_pr(self, mock_github_token, mock_pr_details): + """Status should show PR for current branch if one exists.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[mock_pr_details]) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + with patch( + "codeframe.cli.pr_commands.get_current_branch", + return_value="feature/new-feature", + ): + result = runner.invoke(pr_app, ["status"]) + + assert result.exit_code == 0 + # Should find the PR for the current branch + assert "42" in result.output or "open" in result.output.lower() + + def test_status_no_pr_for_branch(self, mock_github_token): + """Status when no PR exists for current branch.""" + from codeframe.cli.pr_commands import pr_app + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[]) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + with patch( + "codeframe.cli.pr_commands.get_current_branch", + return_value="feature/no-pr", + ): + result = runner.invoke(pr_app, ["status"]) + + assert result.exit_code == 0 + assert "no" in result.output.lower() or "none" in result.output.lower() + + +class TestGitHelpers: + """Tests for git helper functions used by PR commands.""" + + def test_get_current_branch_normal(self, tmp_path): + """Get current branch from normal git repo.""" + import subprocess + + # Create a temporary git repo + subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=tmp_path, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=tmp_path, + capture_output=True, + ) + (tmp_path / "README.md").write_text("# Test") + subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True) + + # Test the helper function + from codeframe.cli.pr_commands import get_current_branch + + with patch("codeframe.cli.pr_commands.Path.cwd", return_value=tmp_path): + branch = get_current_branch(tmp_path) + + assert branch == "main" + + def test_get_git_diff_stats(self, tmp_path): + """Get diff stats between branches.""" + import subprocess + + # Create a temporary git repo with two branches + subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=tmp_path, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=tmp_path, + capture_output=True, + ) + (tmp_path / "README.md").write_text("# Test") + subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=tmp_path, capture_output=True) + + # Create feature branch with changes + subprocess.run(["git", "checkout", "-b", "feature"], cwd=tmp_path, capture_output=True) + (tmp_path / "new_file.py").write_text("print('hello')") + subprocess.run(["git", "add", "."], cwd=tmp_path, capture_output=True) + subprocess.run(["git", "commit", "-m", "add feature"], cwd=tmp_path, capture_output=True) + + # Test the helper function + from codeframe.cli.pr_commands import get_git_diff_stats + + stats = get_git_diff_stats(tmp_path, "main", "feature") + + assert "new_file.py" in stats or "1 file" in stats + + +class TestErrorHandling: + """Tests for error handling in PR commands.""" + + def test_network_error_shows_message(self, mock_github_token): + """Network errors should show user-friendly message.""" + from codeframe.cli.pr_commands import pr_app + from codeframe.git.github_integration import GitHubAPIError + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock( + side_effect=GitHubAPIError(500, "Internal Server Error") + ) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["list"]) + + assert result.exit_code != 0 + assert "error" in result.output.lower() + + def test_rate_limit_shows_message(self, mock_github_token): + """Rate limit errors should show helpful message.""" + from codeframe.cli.pr_commands import pr_app + from codeframe.git.github_integration import GitHubAPIError + + with patch( + "codeframe.cli.pr_commands.GitHubIntegration" + ) as MockGH: + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock( + side_effect=GitHubAPIError(403, "rate limit exceeded") + ) + mock_gh.close = AsyncMock() + MockGH.return_value = mock_gh + + result = runner.invoke(pr_app, ["list"]) + + assert result.exit_code != 0 + assert "rate" in result.output.lower() or "limit" in result.output.lower() or "error" in result.output.lower() From e45cd4bfadfa6b180d4c5b6096d662be773a5824 Mon Sep 17 00:00:00 2001 From: Test User <test@example.com> Date: Wed, 28 Jan 2026 09:21:40 -0700 Subject: [PATCH 2/4] fix(cli): don't show success message when PR already closed Address code review feedback: return None instead of True when PR is already closed to skip the success message, consistent with merge_pr handling of already-merged PRs. --- codeframe/cli/pr_commands.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codeframe/cli/pr_commands.py b/codeframe/cli/pr_commands.py index 1af0881e..8679c7d1 100644 --- a/codeframe/cli/pr_commands.py +++ b/codeframe/cli/pr_commands.py @@ -474,7 +474,7 @@ async def _close(): if pr.state == "closed": console.print(f"[yellow]PR #{pr_number} is already closed.[/yellow]") - return True + return None # Close the PR result = await gh.close_pull_request(pr_number) @@ -484,6 +484,10 @@ async def _close(): result = _run_async(_close()) + if result is None: + # Already closed - message already printed + raise typer.Exit(0) + if result: console.print(f"[green]✓ PR #{pr_number} closed[/green]") else: From afe7b7c6de61eb33602761df878426ff084b0de0 Mon Sep 17 00:00:00 2001 From: Test User <test@example.com> Date: Wed, 28 Jan 2026 09:26:32 -0700 Subject: [PATCH 3/4] ci(workflow): improve opencode-review stability and context passing Improvements from ralph-claude-code workflow: - Add git credentials clearing step to prevent auth conflicts - Pass PR context (title, body, number, repo) as environment variables - Include PR title directly in prompt to avoid gh pr view calls - Add pyproject.toml to paths-ignore to skip config-only changes - Remove gh pr view/diff instructions (info now passed directly) Kept existing: - Concurrency block to prevent duplicate reviews for same PR - 10-minute timeout - Substantial changes threshold (5+ files OR 20+ lines) --- .github/workflows/opencode-review.yml | 42 +++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index f90b31aa..e8dea3b1 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -3,12 +3,13 @@ name: OpenCode PR Review on: pull_request: types: [opened, synchronize] - # Skip review for documentation-only changes + # Skip review for documentation and config-only changes # Exclude this workflow file to prevent self-triggering loops paths-ignore: - "**/*.md" - ".github/workflows/opencode-review.yml" - ".gitignore" + - "pyproject.toml" # Cancel in-progress runs for the same PR to avoid duplicate reviews concurrency: @@ -29,10 +30,10 @@ jobs: - name: Calculate total changes id: calc run: | - additions="${{ github.event.pull_request.additions }}" - deletions="${{ github.event.pull_request.deletions }}" + additions=${{ github.event.pull_request.additions }} + deletions=${{ github.event.pull_request.deletions }} total=$((additions + deletions)) - echo "total=$total" >> "$GITHUB_OUTPUT" + echo "total=$total" >> $GITHUB_OUTPUT - name: Checkout repository # Only review substantial changes (5+ files OR 20+ lines changed) @@ -44,6 +45,31 @@ jobs: fetch-depth: 1 persist-credentials: false + - name: Clear git credentials to avoid duplicate auth + if: | + github.event.pull_request.changed_files >= 5 || + steps.calc.outputs.total >= 20 + run: | + # Clear all GitHub-related git config to prevent auth conflicts + git config --global --unset-all http.https://github.com/.extraheader || true + git config --local --unset-all http.https://github.com/.extraheader || true + git config --global --unset-all credential.helper || true + git config --local --unset-all credential.helper || true + git config --global --unset-all credential."https://github.com".helper || true + git config --local --unset-all credential."https://github.com".helper || true + # Remove any credential URLs + git config --global --unset-all credential.url || true + git config --local --unset-all credential.url || true + # Clear any includeIf configs that might add credentials + # Note: git config doesn't support wildcards, so we iterate over matching keys + # Use case-insensitive grep to catch both "includeIf" and "includeif" + for key in $(git config --global --list --name-only 2>/dev/null | grep -i "^includeif\." || true); do + git config --global --unset "$key" || true + done + for key in $(git config --local --list --name-only 2>/dev/null | grep -i "^includeif\." || true); do + git config --local --unset "$key" || true + done + - name: Run OpenCode PR Review # Only review substantial changes (5+ files OR 20+ lines changed) if: | @@ -53,14 +79,18 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} + # Pass PR context as environment variables for the review + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + REPO_NAME: ${{ github.repository }} with: model: zai-coding-plan/glm-4.7 use_github_token: true prompt: | You are reviewing PR #${{ github.event.pull_request.number }} in repository ${{ github.repository }}. - Use `gh pr view ${{ github.event.pull_request.number }}` to read the PR title and description. - Use `gh pr diff ${{ github.event.pull_request.number }}` to read the changed files. + PR TITLE: ${{ github.event.pull_request.title }} Please review this pull request and provide feedback on: - Code quality and best practices From 5f235f986792b97988d1d1a53c9858516ca0797c Mon Sep 17 00:00:00 2001 From: Test User <test@example.com> Date: Wed, 28 Jan 2026 09:32:54 -0700 Subject: [PATCH 4/4] test(cli): add PR commands to v2 CLI integration tests Add TestPRCommands class with 9 tests covering: - pr help - pr list (table and JSON formats) - pr get - pr create - pr merge - pr close - pr status - Missing token error handling Tests use monkeypatch to mock GitHubIntegration, following the existing pattern for AI integration tests with MockProvider. All tests are marked v2 via the module-level pytestmark. --- tests/cli/test_v2_cli_integration.py | 187 +++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/tests/cli/test_v2_cli_integration.py b/tests/cli/test_v2_cli_integration.py index db88caad..60a6df7c 100644 --- a/tests/cli/test_v2_cli_integration.py +++ b/tests/cli/test_v2_cli_integration.py @@ -881,3 +881,190 @@ def test_ai_golden_path(self, temp_repo, mock_llm): # Verify the file the agent created assert (temp_repo / "hello.py").exists() + + +# --------------------------------------------------------------------------- +# 16. PR commands (GitHub integration) +# --------------------------------------------------------------------------- + + +class TestPRCommands: + """Tests for PR CLI commands with mocked GitHub API.""" + + @pytest.fixture + def mock_github_env(self, monkeypatch): + """Set up mock GitHub environment variables.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test_token_12345") + monkeypatch.setenv("GITHUB_REPO", "testowner/testrepo") + + @pytest.fixture + def mock_pr_details(self): + """Mock PRDetails response.""" + from datetime import datetime, UTC + from codeframe.git.github_integration import PRDetails + + return PRDetails( + number=42, + url="https://github.com/testowner/testrepo/pull/42", + state="open", + title="Test PR", + body="Test description", + created_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC), + merged_at=None, + head_branch="feature/test", + base_branch="main", + ) + + def test_pr_help(self): + """PR command group shows help.""" + result = runner.invoke(app, ["pr", "--help"]) + assert result.exit_code == 0 + assert "create" in result.output + assert "list" in result.output + assert "merge" in result.output + + def test_pr_list(self, mock_github_env, mock_pr_details, monkeypatch): + """PR list command displays PRs.""" + from unittest.mock import AsyncMock + + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[mock_pr_details]) + mock_gh.close = AsyncMock() + + monkeypatch.setattr( + "codeframe.cli.pr_commands.GitHubIntegration", + lambda **kwargs: mock_gh, + ) + + result = runner.invoke(app, ["pr", "list"]) + assert result.exit_code == 0 + assert "42" in result.output + + def test_pr_list_json(self, mock_github_env, mock_pr_details, monkeypatch): + """PR list with JSON format.""" + from unittest.mock import AsyncMock + + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[mock_pr_details]) + mock_gh.close = AsyncMock() + + monkeypatch.setattr( + "codeframe.cli.pr_commands.GitHubIntegration", + lambda **kwargs: mock_gh, + ) + + result = runner.invoke(app, ["pr", "list", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert isinstance(data, list) + assert data[0]["number"] == 42 + + def test_pr_get(self, mock_github_env, mock_pr_details, monkeypatch): + """PR get command shows PR details.""" + from unittest.mock import AsyncMock + + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close = AsyncMock() + + monkeypatch.setattr( + "codeframe.cli.pr_commands.GitHubIntegration", + lambda **kwargs: mock_gh, + ) + + result = runner.invoke(app, ["pr", "get", "42"]) + assert result.exit_code == 0 + assert "42" in result.output + assert "Test PR" in result.output + + def test_pr_create(self, mock_github_env, mock_pr_details, monkeypatch): + """PR create command creates a PR.""" + from unittest.mock import AsyncMock + + mock_gh = AsyncMock() + mock_gh.create_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close = AsyncMock() + + monkeypatch.setattr( + "codeframe.cli.pr_commands.GitHubIntegration", + lambda **kwargs: mock_gh, + ) + monkeypatch.setattr( + "codeframe.cli.pr_commands.get_current_branch", + lambda *args: "feature/test", + ) + + result = runner.invoke( + app, ["pr", "create", "--title", "Test PR", "--no-auto-description"] + ) + assert result.exit_code == 0 + assert "42" in result.output or "created" in result.output.lower() + + def test_pr_merge(self, mock_github_env, mock_pr_details, monkeypatch): + """PR merge command merges a PR.""" + from unittest.mock import AsyncMock + from codeframe.git.github_integration import MergeResult + + merge_result = MergeResult(sha="abc123", merged=True, message="Merged") + + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.merge_pull_request = AsyncMock(return_value=merge_result) + mock_gh.close = AsyncMock() + + monkeypatch.setattr( + "codeframe.cli.pr_commands.GitHubIntegration", + lambda **kwargs: mock_gh, + ) + + result = runner.invoke(app, ["pr", "merge", "42"]) + assert result.exit_code == 0 + assert "merged" in result.output.lower() + + def test_pr_close(self, mock_github_env, mock_pr_details, monkeypatch): + """PR close command closes a PR.""" + from unittest.mock import AsyncMock + + mock_gh = AsyncMock() + mock_gh.get_pull_request = AsyncMock(return_value=mock_pr_details) + mock_gh.close_pull_request = AsyncMock(return_value=True) + mock_gh.close = AsyncMock() + + monkeypatch.setattr( + "codeframe.cli.pr_commands.GitHubIntegration", + lambda **kwargs: mock_gh, + ) + + result = runner.invoke(app, ["pr", "close", "42"]) + assert result.exit_code == 0 + assert "closed" in result.output.lower() + + def test_pr_status(self, mock_github_env, mock_pr_details, monkeypatch): + """PR status command shows status for current branch.""" + from unittest.mock import AsyncMock + + mock_gh = AsyncMock() + mock_gh.list_pull_requests = AsyncMock(return_value=[mock_pr_details]) + mock_gh.close = AsyncMock() + + monkeypatch.setattr( + "codeframe.cli.pr_commands.GitHubIntegration", + lambda **kwargs: mock_gh, + ) + monkeypatch.setattr( + "codeframe.cli.pr_commands.get_current_branch", + lambda *args: "feature/test", + ) + + result = runner.invoke(app, ["pr", "status"]) + assert result.exit_code == 0 + assert "42" in result.output or "open" in result.output.lower() + + def test_pr_no_token_error(self, monkeypatch): + """PR commands without GITHUB_TOKEN show helpful error.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_REPO", raising=False) + + result = runner.invoke(app, ["pr", "list"]) + assert result.exit_code != 0 + assert "github" in result.output.lower() or "token" in result.output.lower()