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
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..8679c7d1
--- /dev/null
+++ b/codeframe/cli/pr_commands.py
@@ -0,0 +1,549 @@
+"""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 None
+
+ # Close the PR
+ result = await gh.close_pull_request(pr_number)
+ return result
+ finally:
+ await gh.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:
+ 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 ] [--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 [--strategy squash|merge|rebase]`
-**Purpose:** Merge pull request with verification.
+### `codeframe pr get [--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 [--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 `
+**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()
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()