diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 81f83bb58c..f69399a61d 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -8,17 +8,20 @@ branding: color: blue inputs: + mode: + description: "Review mode: 'sdk' (run locally) or 'cloud' (run in OpenHands Cloud)" + required: false + default: sdk llm-model: - description: LLM model to use for the review + description: LLM model to use for the review (sdk mode only, cloud uses user's configured LLM) required: false default: anthropic/claude-sonnet-4-5-20250929 llm-base-url: - description: LLM base URL (optional, for custom LLM endpoints) + description: LLM base URL (sdk mode only, optional for custom LLM endpoints) required: false default: '' review-style: - description: "Review style: 'standard' (balanced review covering style, readability, and security) or 'roasted' (Linus Torvalds-style brutally honest - feedback focusing on data structures, simplicity, and pragmatism)" + description: "Review style: 'standard' (balanced review) or 'roasted' (Linus Torvalds-style)" required: false default: roasted sdk-repo: @@ -26,17 +29,26 @@ inputs: required: false default: OpenHands/software-agent-sdk sdk-version: - description: Git ref to use for the SDK (tag, branch, or commit SHA, e.g., v1.0.0, main, or abc1234) + description: Git ref to use for the SDK (tag, branch, or commit SHA) required: false default: main llm-api-key: - description: LLM API key (required) - required: true + description: LLM API key (required for 'sdk' mode only - cloud mode uses your OpenHands Cloud LLM settings) + required: false + default: '' github-token: - description: GitHub token for API access (required) + description: GitHub token for API access (required for both modes - used to post initial comment in cloud mode) required: true + openhands-cloud-api-key: + description: OpenHands Cloud API key (required for 'cloud' mode), get it from https://app.all-hands.dev/settings/api-keys + required: false + default: '' + openhands-cloud-api-url: + description: OpenHands Cloud API URL + required: false + default: https://app.all-hands.dev lmnr-api-key: - description: Laminar API key for observability (optional) + description: Laminar API key for observability (optional, sdk mode only) required: false default: '' @@ -78,20 +90,40 @@ runs: - name: Install OpenHands dependencies shell: bash run: | - uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools lmnr + uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools ./software-agent-sdk/openhands-workspace lmnr requests - name: Check required configuration shell: bash env: + MODE: ${{ inputs.mode }} LLM_API_KEY: ${{ inputs.llm-api-key }} GITHUB_TOKEN: ${{ inputs.github-token }} + OPENHANDS_CLOUD_API_KEY: ${{ inputs.openhands-cloud-api-key }} run: | - if [ -z "$LLM_API_KEY" ]; then - echo "Error: llm-api-key is required." + echo "Mode: $MODE" + + # GITHUB_TOKEN is required for both modes + if [ -z "$GITHUB_TOKEN" ]; then + echo "Error: github-token is required for both modes." exit 1 fi - if [ -z "$GITHUB_TOKEN" ]; then - echo "Error: github-token is required." + + if [ "$MODE" = "sdk" ]; then + # SDK mode requires LLM_API_KEY for local execution + if [ -z "$LLM_API_KEY" ]; then + echo "Error: llm-api-key is required for 'sdk' mode." + exit 1 + fi + echo "SDK mode: LLM runs locally in GitHub Actions" + elif [ "$MODE" = "cloud" ]; then + # Cloud mode requires OPENHANDS_CLOUD_API_KEY (no LLM_API_KEY needed) + if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then + echo "Error: openhands-cloud-api-key is required for 'cloud' mode." + exit 1 + fi + echo "Cloud mode: Review runs in OpenHands Cloud using your configured LLM" + else + echo "Error: mode must be 'sdk' or 'cloud', got '$MODE'." exit 1 fi @@ -99,19 +131,27 @@ runs: echo "PR Title: ${{ github.event.pull_request.title }}" echo "Repository: ${{ github.repository }}" echo "SDK Version: ${{ inputs.sdk-version }}" - echo "LLM model: ${{ inputs.llm-model }}" - if [ -n "${{ inputs.llm-base-url }}" ]; then - echo "LLM base URL: ${{ inputs.llm-base-url }}" + if [ "$MODE" = "sdk" ]; then + echo "LLM model: ${{ inputs.llm-model }}" + if [ -n "${{ inputs.llm-base-url }}" ]; then + echo "LLM base URL: ${{ inputs.llm-base-url }}" + fi + else + echo "OpenHands Cloud API URL: ${{ inputs.openhands-cloud-api-url }}" + echo "Note: LLM is configured in your OpenHands Cloud account" fi - name: Run PR review shell: bash env: + MODE: ${{ inputs.mode }} LLM_MODEL: ${{ inputs.llm-model }} LLM_BASE_URL: ${{ inputs.llm-base-url }} REVIEW_STYLE: ${{ inputs.review-style }} LLM_API_KEY: ${{ inputs.llm-api-key }} GITHUB_TOKEN: ${{ inputs.github-token }} + OPENHANDS_CLOUD_API_KEY: ${{ inputs.openhands-cloud-api-key }} + OPENHANDS_CLOUD_API_URL: ${{ inputs.openhands-cloud-api-url }} LMNR_PROJECT_API_KEY: ${{ inputs.lmnr-api-key }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index 8cf90033a4..aa9e9d3955 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -13,6 +13,9 @@ This example demonstrates how to set up a GitHub Actions workflow for automated ## Features +- **Two Review Modes**: + - **SDK Mode** (default): Runs the agent locally in GitHub Actions + - **Cloud Mode**: Launches the review in OpenHands Cloud for faster CI completion - **Automatic Trigger**: Reviews are triggered when: - The `review-this` label is added to a PR, OR - openhands-agent is requested as a reviewer @@ -54,21 +57,33 @@ cp examples/03_github_workflows/02_pr_review/workflow.yml .github/workflows/pr-r ### 2. Configure secrets -Set the following secrets in your GitHub repository settings: +Set the following secrets in your GitHub repository settings based on your chosen mode: +**For SDK Mode (default):** - **`LLM_API_KEY`** (required): Your LLM API key - Get one from the [OpenHands LLM Provider](https://docs.all-hands.dev/openhands/usage/llms/openhands-llms) +- **`GITHUB_TOKEN`** (auto-available): Used for PR diff and posting comments -**Note**: The workflow automatically uses the `GITHUB_TOKEN` secret that's available in all GitHub Actions workflows. +**For Cloud Mode:** +- **`OPENHANDS_CLOUD_API_KEY`** (required): Your OpenHands Cloud API key + - Get one from your [OpenHands Cloud account settings](https://app.all-hands.dev/settings/api-keys) +- **`GITHUB_TOKEN`** (auto-available): Used to post initial comment with conversation URL +- **`LLM_API_KEY`** (optional): Your LLM API key. If not provided, uses the LLM configured in your OpenHands Cloud account. + +**Note**: Cloud mode uses the LLM settings configured in your OpenHands Cloud account by default. You can optionally override this by providing `LLM_API_KEY`. The workflow uses `GITHUB_TOKEN` to post a comment linking to the conversation URL. The agent running in cloud uses your account's GitHub access for the actual review. ### 3. Customize the workflow (optional) -Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs: +Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. + +**SDK Mode Configuration (default):** ```yaml - name: Run PR Review uses: ./.github/actions/pr-review with: + # Review mode: 'sdk' runs the agent locally in GitHub Actions + mode: sdk # LLM configuration llm-model: anthropic/claude-sonnet-4-5-20250929 llm-base-url: '' @@ -83,6 +98,38 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs: github-token: ${{ secrets.GITHUB_TOKEN }} ``` +**Cloud Mode Configuration:** + +```yaml +- name: Run PR Review + uses: ./.github/actions/pr-review + with: + # Review mode: 'cloud' runs in OpenHands Cloud + mode: cloud + # Review style: roasted (other option: standard) + review-style: roasted + # SDK git ref to use + sdk-version: main + # Cloud mode secrets + openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} + # Optional: Override the cloud's default LLM with your own + # llm-api-key: ${{ secrets.LLM_API_KEY }} + # llm-model: anthropic/claude-sonnet-4-5-20250929 + # Optional: custom cloud API URL + # openhands-cloud-api-url: https://app.all-hands.dev +``` + +**Cloud Mode Benefits:** +- **Faster CI completion**: Starts the review and exits immediately +- **Track progress in UI**: Posts a comment with a link to the conversation URL +- **Interactive**: Users can interact with the review conversation in the cloud UI + +**Cloud Mode Prerequisites:** +> ⚠️ The OpenHands Cloud account that owns the `OPENHANDS_CLOUD_API_KEY` must have GitHub access to the repository you want to review. The agent running in cloud uses your account's GitHub credentials to fetch the PR diff and post review comments. +> +> Follow the [GitHub Installation Guide](https://docs.openhands.dev/openhands/usage/cloud/github-installation) to connect your GitHub account to OpenHands Cloud. + ### 4. Create the review label Create a `review-this` label in your repository: @@ -184,14 +231,17 @@ This workflow uses a reusable composite action located at `.github/actions/pr-re | Input | Description | Required | Default | |-------|-------------|----------|---------| +| `mode` | Review mode: 'sdk' or 'cloud' | No | `sdk` | | `llm-model` | LLM model to use | No | `anthropic/claude-sonnet-4-5-20250929` | -| `llm-base-url` | LLM base URL (optional) | No | `''` | +| `llm-base-url` | LLM base URL (optional for custom endpoints) | No | `''` | | `review-style` | Review style: 'standard' or 'roasted' | No | `roasted` | | `sdk-version` | Git ref for SDK (tag, branch, or commit SHA) | No | `main` | | `sdk-repo` | SDK repository (owner/repo) | No | `OpenHands/software-agent-sdk` | -| `llm-api-key` | LLM API key | Yes | - | +| `llm-api-key` | LLM API key (required for SDK mode, optional for cloud mode) | SDK mode | - | | `github-token` | GitHub token for API access | Yes | - | -| `lmnr-api-key` | Laminar API key for observability (optional) | No | - | +| `openhands-cloud-api-key` | OpenHands Cloud API key (cloud mode only) | cloud mode | - | +| `openhands-cloud-api-url` | OpenHands Cloud API URL | No | `https://app.all-hands.dev` | +| `lmnr-api-key` | Laminar API key for observability (sdk mode only) | No | - | ## Review Evaluation (Observability) diff --git a/examples/03_github_workflows/02_pr_review/agent_script.py b/examples/03_github_workflows/02_pr_review/agent_script.py index fcfb5de2e4..a579bee025 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 -""" -Example: PR Review Agent +"""PR Review Agent - Automated code review using OpenHands. + +Supports two modes: +- 'sdk': Run locally using the SDK (default) +- 'cloud': Run in OpenHands Cloud using OpenHandsCloudWorkspace This script runs OpenHands agent to review a pull request and provide fine-grained review comments. The agent has full repository access and uses @@ -22,7 +25,8 @@ Designed for use with GitHub Actions workflows triggered by PR labels. Environment Variables: - LLM_API_KEY: API key for the LLM (required) + MODE: Review mode ('sdk' or 'cloud', default: 'sdk') + LLM_API_KEY: API key for the LLM (required for SDK mode) LLM_MODEL: Language model to use (default: anthropic/claude-sonnet-4-5-20250929) LLM_BASE_URL: Optional base URL for LLM API GITHUB_TOKEN: GitHub token for API access (required) @@ -33,6 +37,8 @@ PR_HEAD_BRANCH: Head branch name (required) REPO_NAME: Repository name in format owner/repo (required) REVIEW_STYLE: Review style ('standard' or 'roasted', default: 'standard') + OPENHANDS_CLOUD_API_KEY: API key for OpenHands Cloud (required for cloud mode) + OPENHANDS_CLOUD_API_URL: OpenHands Cloud API URL (default: https://app.all-hands.dev) For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. @@ -50,13 +56,8 @@ from pathlib import Path from typing import Any -from lmnr import Laminar - -from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger -from openhands.sdk.context.skills import load_project_skills -from openhands.sdk.conversation import get_agent_final_response +from openhands.sdk import get_logger from openhands.sdk.git.utils import run_git_command -from openhands.tools.preset.default import get_default_condenser, get_default_tools # Add the script directory to Python path so we can import prompt.py @@ -78,6 +79,7 @@ def _get_required_env(name: str) -> str: + """Get a required environment variable or raise ValueError.""" value = os.getenv(name) if not value: raise ValueError(f"{name} environment variable is required") @@ -617,6 +619,7 @@ def get_pr_diff_via_github_api(pr_number: str) -> str: def truncate_text(diff_text: str, max_total: int = MAX_TOTAL_DIFF) -> str: + """Truncate text to a maximum length.""" if len(diff_text) <= max_total: return diff_text @@ -641,8 +644,7 @@ def get_truncated_pr_diff() -> str: def get_head_commit_sha(repo_dir: Path | None = None) -> str: - """ - Get the SHA of the HEAD commit. + """Get the SHA of the HEAD commit. Args: repo_dir: Path to the repository (defaults to cwd) @@ -656,13 +658,9 @@ def get_head_commit_sha(repo_dir: Path | None = None) -> str: return run_git_command(["git", "rev-parse", "HEAD"], repo_dir).strip() -def main(): - """Run the PR review agent.""" - logger.info("Starting PR review process...") - - # Validate required environment variables - required_vars = [ - "LLM_API_KEY", +def _get_required_vars_for_mode(mode: str) -> list[str]: + """Get required environment variables for the given mode.""" + common_vars = [ "GITHUB_TOKEN", "PR_NUMBER", "PR_TITLE", @@ -670,42 +668,64 @@ def main(): "PR_HEAD_BRANCH", "REPO_NAME", ] + if mode == "cloud": + # Cloud mode only requires OPENHANDS_CLOUD_API_KEY + # LLM is configured in the user's OpenHands Cloud account + return ["OPENHANDS_CLOUD_API_KEY"] + common_vars + # SDK mode requires LLM_API_KEY for local LLM execution + return ["LLM_API_KEY"] + common_vars + + +def _get_pr_info() -> dict[str, Any]: + """Get PR information from environment variables.""" + return { + "number": os.getenv("PR_NUMBER", ""), + "title": os.getenv("PR_TITLE", ""), + "body": os.getenv("PR_BODY", ""), + "repo_name": os.getenv("REPO_NAME", ""), + "base_branch": os.getenv("PR_BASE_BRANCH", ""), + "head_branch": os.getenv("PR_HEAD_BRANCH", ""), + } + +def main() -> None: + """Run the PR review agent.""" + logger.info("Starting PR review process...") + + mode = os.getenv("MODE", "sdk").lower() + if mode not in ("sdk", "cloud"): + logger.warning(f"Unknown MODE '{mode}', using 'sdk'") + mode = "sdk" + logger.info(f"Mode: {mode}") + + required_vars = _get_required_vars_for_mode(mode) missing_vars = [var for var in required_vars if not os.getenv(var)] if missing_vars: logger.error(f"Missing required environment variables: {missing_vars}") sys.exit(1) - github_token = os.getenv("GITHUB_TOKEN") - - # Get PR information - pr_info = { - "number": os.getenv("PR_NUMBER"), - "title": os.getenv("PR_TITLE"), - "body": os.getenv("PR_BODY", ""), - "repo_name": os.getenv("REPO_NAME"), - "base_branch": os.getenv("PR_BASE_BRANCH"), - "head_branch": os.getenv("PR_HEAD_BRANCH"), - } + pr_info = _get_pr_info() + logger.info(f"Reviewing PR #{pr_info['number']}: {pr_info['title']}") - # Get review style - default to standard review_style = os.getenv("REVIEW_STYLE", "standard").lower() if review_style not in ("standard", "roasted"): logger.warning(f"Unknown REVIEW_STYLE '{review_style}', using 'standard'") review_style = "standard" - - logger.info(f"Reviewing PR #{pr_info['number']}: {pr_info['title']}") logger.info(f"Review style: {review_style}") + skill_trigger = ( + "/codereview" if review_style == "standard" else "/codereview-roasted" + ) + try: + # Get PR diff and context (used by SDK mode, cloud mode fetches its own) pr_diff = get_truncated_pr_diff() logger.info(f"Got PR diff with {len(pr_diff)} characters") - # Get the HEAD commit SHA for inline comments commit_id = get_head_commit_sha() logger.info(f"HEAD commit SHA: {commit_id}") - # Fetch previous review context (comments, threads, resolution status) + # Fetch previous review context pr_number = pr_info.get("number", "") review_context = get_pr_review_context(pr_number) if review_context: @@ -714,10 +734,6 @@ def main(): logger.info("No previous review context found") # Create the review prompt using the template - # Include the skill trigger keyword to activate the appropriate skill - skill_trigger = ( - "/codereview" if review_style == "standard" else "/codereview-roasted" - ) prompt = format_prompt( skill_trigger=skill_trigger, title=pr_info.get("title", "N/A"), @@ -731,164 +747,20 @@ def main(): review_context=review_context, ) - # Configure LLM - api_key = os.getenv("LLM_API_KEY") - model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") - base_url = os.getenv("LLM_BASE_URL") - - llm_config = { - "model": model, - "api_key": api_key, - "usage_id": "pr_review_agent", - "drop_params": True, - } - - if base_url: - llm_config["base_url"] = base_url - - llm = LLM(**llm_config) - - # Get the current working directory as workspace - cwd = os.getcwd() - - # Load project-specific skills from the repository being reviewed - # This includes AGENTS.md, .cursorrules, and skills from .agents/skills/ - project_skills = load_project_skills(cwd) - logger.info( - f"Loaded {len(project_skills)} project skills: " - f"{[s.name for s in project_skills]}" - ) - - # Create AgentContext with public skills enabled and project skills - # Public skills from https://github.com/OpenHands/skills include: - # - /codereview: Standard code review skill - # - /codereview-roasted: Linus Torvalds style brutally honest review - # Project skills include repo-specific guidance (AGENTS.md, etc.) - agent_context = AgentContext( - load_public_skills=True, - skills=project_skills, - ) - - # Create agent with default tools and agent context - # Note: agent_context must be passed at initialization since Agent is frozen - agent = Agent( - llm=llm, - tools=get_default_tools(enable_browser=False), # CLI mode - no browser - agent_context=agent_context, - system_prompt_kwargs={"cli_mode": True}, - condenser=get_default_condenser( - llm=llm.model_copy(update={"usage_id": "condenser"}) - ), - ) - - # Create conversation with secrets for masking - # These secrets will be masked in agent output to prevent accidental exposure - secrets = {} - if api_key: - secrets["LLM_API_KEY"] = api_key - if github_token: - secrets["GITHUB_TOKEN"] = github_token - - conversation = Conversation( - agent=agent, - workspace=cwd, - secrets=secrets, - ) - - logger.info("Starting PR review analysis...") - logger.info("Agent received the PR diff in the initial message") logger.info(f"Using skill trigger: {skill_trigger}") - logger.info("Agent will post inline review comments directly via GitHub API") - - # Send the prompt and run the agent - # The agent will analyze the code and post inline review comments - # directly to the PR using the GitHub API - conversation.send_message(prompt) - conversation.run() - - # The agent should have posted review comments via GitHub API - # Log the final response for debugging purposes - review_content = get_agent_final_response(conversation.state.events) - if review_content: - logger.info(f"Agent final response: {len(review_content)} characters") - - # Print cost information for CI output - metrics = conversation.conversation_stats.get_combined_metrics() - print("\n=== PR Review Cost Summary ===") - print(f"Total Cost: ${metrics.accumulated_cost:.6f}") - if metrics.accumulated_token_usage: - token_usage = metrics.accumulated_token_usage - print(f"Prompt Tokens: {token_usage.prompt_tokens}") - print(f"Completion Tokens: {token_usage.completion_tokens}") - if token_usage.cache_read_tokens > 0: - print(f"Cache Read Tokens: {token_usage.cache_read_tokens}") - if token_usage.cache_write_tokens > 0: - print(f"Cache Write Tokens: {token_usage.cache_write_tokens}") - - # Capture and store trace context for delayed evaluation - # When the PR is merged/closed, we can use this context to add the - # evaluation span to the same trace, enabling signals to analyze both - # the original review and evaluation together. - # Note: Laminar methods gracefully handle the uninitialized case by - # returning None or early-returning, so no try/except needed. - trace_id = Laminar.get_trace_id() - # Use model_dump(mode='json') to ensure UUIDs are serialized as strings - # for JSON compatibility. get_laminar_span_context_dict() returns UUID - # objects which are not JSON serializable. - laminar_span_context = Laminar.get_laminar_span_context() - span_context = ( - laminar_span_context.model_dump(mode="json") - if laminar_span_context - else None - ) - - if trace_id and laminar_span_context: - # Set trace metadata within an active span context - # Using start_as_current_span with parent_span_context to continue the trace - with Laminar.start_as_current_span( - name="pr-review-metadata", - parent_span_context=laminar_span_context, - ) as _: - # Set trace metadata within this active span context - pr_url = f"https://github.com/{pr_info['repo_name']}/pull/{pr_info['number']}" - Laminar.set_trace_metadata( - { - "pr_number": pr_info["number"], - "repo_name": pr_info["repo_name"], - "pr_url": pr_url, - "workflow_phase": "review", - "review_style": review_style, - } - ) - # Store trace context in file for GitHub artifact upload - # This allows the evaluation workflow to add its span to this trace - # The span_context includes trace_id, span_id, and span_path needed - # to continue the trace across separate workflow runs. - trace_data = { - "trace_id": str(trace_id), - "span_context": span_context, - "pr_number": pr_info["number"], - "repo_name": pr_info["repo_name"], - "commit_id": commit_id, - "review_style": review_style, - } - with open("laminar_trace_info.json", "w") as f: - json.dump(trace_data, f, indent=2) - logger.info(f"Laminar trace ID: {trace_id}") - if span_context: - logger.info("Laminar span context captured for trace continuation") - print("\n=== Laminar Trace ===") - print(f"Trace ID: {trace_id}") - - # Ensure trace is flushed to Laminar before workflow ends - Laminar.flush() + # Import and run the appropriate mode + if mode == "cloud": + from utils.cloud_mode import run_agent_review else: - logger.warning( - "No Laminar trace ID found - observability may not be enabled" - ) + from utils.sdk_mode import run_agent_review - logger.info("PR review completed successfully") + run_agent_review( + prompt=prompt, + pr_info=pr_info, + commit_id=commit_id, + review_style=review_style, + ) except Exception as e: logger.error(f"PR review failed: {e}") diff --git a/examples/03_github_workflows/02_pr_review/utils/__init__.py b/examples/03_github_workflows/02_pr_review/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/03_github_workflows/02_pr_review/utils/agent_util.py b/examples/03_github_workflows/02_pr_review/utils/agent_util.py new file mode 100644 index 0000000000..308734e0e0 --- /dev/null +++ b/examples/03_github_workflows/02_pr_review/utils/agent_util.py @@ -0,0 +1,185 @@ +"""Shared agent utilities for PR review. + +This module provides shared abstractions for creating and configuring the +OpenHands agent used in PR review, including LLM configuration, AgentContext, +and conversation management. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from lmnr import Laminar + +from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger +from openhands.sdk.context.skills import load_project_skills +from openhands.sdk.conversation.base import BaseConversation +from openhands.tools.preset.default import get_default_condenser, get_default_tools + + +logger = get_logger(__name__) + + +def create_llm( + api_key: str | None = None, + model: str | None = None, + base_url: str | None = None, +) -> LLM: + """Create an LLM instance with the given configuration. + + Args: + api_key: LLM API key (defaults to LLM_API_KEY env var) + model: Model name (defaults to LLM_MODEL env var or claude-sonnet-4-5) + base_url: Base URL for LLM API (defaults to LLM_BASE_URL env var) + + Returns: + Configured LLM instance + """ + api_key = api_key or os.getenv("LLM_API_KEY") + model = model or os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + base_url = base_url or os.getenv("LLM_BASE_URL") + + llm_config: dict[str, Any] = { + "model": model, + "usage_id": "pr_review_agent", + "drop_params": True, + } + if api_key: + llm_config["api_key"] = api_key + if base_url: + llm_config["base_url"] = base_url + + return LLM(**llm_config) + + +def create_agent(llm: LLM, workspace_path: str | None = None) -> Agent: + """Create an Agent instance with default tools and project skills. + + Args: + llm: LLM instance to use + workspace_path: Path to workspace for loading project skills (defaults to cwd) + + Returns: + Configured Agent instance + """ + workspace_path = workspace_path or os.getcwd() + + # Load project-specific skills from the repository being reviewed + project_skills = load_project_skills(workspace_path) + logger.info( + f"Loaded {len(project_skills)} project skills: " + f"{[s.name for s in project_skills]}" + ) + + # Create AgentContext with public skills enabled and project skills + agent_context = AgentContext( + load_public_skills=True, + skills=project_skills, + ) + + # Create agent with default tools and agent context + return Agent( + llm=llm, + tools=get_default_tools(enable_browser=False), + agent_context=agent_context, + system_prompt_kwargs={"cli_mode": True}, + condenser=get_default_condenser( + llm=llm.model_copy(update={"usage_id": "condenser"}) + ), + ) + + +def create_conversation( + agent: Agent, + workspace: Any, + secrets: dict[str, str] | None = None, +) -> BaseConversation: + """Create a Conversation instance. + + Args: + agent: Agent instance to use + workspace: Workspace path (str) or Workspace instance + secrets: Secrets to mask in agent output + + Returns: + Configured Conversation instance (LocalConversation or RemoteConversation) + """ + # Conversation is a factory that returns LocalConversation or RemoteConversation + # based on the workspace type + conv: BaseConversation = Conversation( # type: ignore[assignment] + agent=agent, + workspace=workspace, + secrets=secrets or {}, + ) + return conv + + +def print_cost_summary(conversation: BaseConversation) -> None: + """Print cost information for CI output.""" + metrics = conversation.conversation_stats.get_combined_metrics() + print("\n=== PR Review Cost Summary ===") + print(f"Total Cost: ${metrics.accumulated_cost:.6f}") + if metrics.accumulated_token_usage: + token_usage = metrics.accumulated_token_usage + print(f"Prompt Tokens: {token_usage.prompt_tokens}") + print(f"Completion Tokens: {token_usage.completion_tokens}") + if token_usage.cache_read_tokens > 0: + print(f"Cache Read Tokens: {token_usage.cache_read_tokens}") + if token_usage.cache_write_tokens > 0: + print(f"Cache Write Tokens: {token_usage.cache_write_tokens}") + + +def save_laminar_trace( + pr_info: dict[str, Any], + commit_id: str, + review_style: str, +) -> None: + """Save Laminar trace info for delayed evaluation.""" + trace_id = Laminar.get_trace_id() + laminar_span_context = Laminar.get_laminar_span_context() + span_context = ( + laminar_span_context.model_dump(mode="json") if laminar_span_context else None + ) + + if trace_id and laminar_span_context: + # Set trace metadata within an active span context + with Laminar.start_as_current_span( + name="pr-review-metadata", + parent_span_context=laminar_span_context, + ) as _: + pr_url = ( + f"https://github.com/{pr_info['repo_name']}/pull/{pr_info['number']}" + ) + Laminar.set_trace_metadata( + { + "pr_number": pr_info["number"], + "repo_name": pr_info["repo_name"], + "pr_url": pr_url, + "workflow_phase": "review", + "review_style": review_style, + } + ) + + # Store trace context in file for GitHub artifact upload + trace_data = { + "trace_id": str(trace_id), + "span_context": span_context, + "pr_number": pr_info["number"], + "repo_name": pr_info["repo_name"], + "commit_id": commit_id, + "review_style": review_style, + } + with open("laminar_trace_info.json", "w") as f: + json.dump(trace_data, f, indent=2) + logger.info(f"Laminar trace ID: {trace_id}") + if span_context: + logger.info("Laminar span context captured for trace continuation") + print("\n=== Laminar Trace ===") + print(f"Trace ID: {trace_id}") + + # Ensure trace is flushed to Laminar before workflow ends + Laminar.flush() + else: + logger.warning("No Laminar trace ID found - observability may not be enabled") diff --git a/examples/03_github_workflows/02_pr_review/utils/cloud_mode.py b/examples/03_github_workflows/02_pr_review/utils/cloud_mode.py new file mode 100644 index 0000000000..a0e409614f --- /dev/null +++ b/examples/03_github_workflows/02_pr_review/utils/cloud_mode.py @@ -0,0 +1,196 @@ +"""Cloud Mode - Run PR review in OpenHands Cloud using OpenHandsCloudWorkspace. + +This module provides the cloud implementation for PR review, which creates a +cloud sandbox and starts the review asynchronously. The workflow exits immediately +after starting the review, and users can track progress in the OpenHands Cloud UI. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from typing import Any + +from openhands.sdk import get_logger +from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation +from openhands.workspace import OpenHandsCloudWorkspace + +from .agent_util import create_agent, create_conversation, create_llm + + +logger = get_logger(__name__) + + +# Prompt template for cloud mode - agent fetches the PR diff itself +CLOUD_MODE_PROMPT = """{skill_trigger} +/github-pr-review + +Review the PR and identify issues that need to be addressed. + +## Pull Request Information +- **Repository**: {repo_name} +- **PR Number**: {pr_number} +- **Title**: {title} +- **Description**: {body} +- **Base Branch**: {base_branch} +- **Head Branch**: {head_branch} + +## Instructions + +1. First, clone the repository and fetch the PR diff: + ```bash + gh pr diff {pr_number} --repo {repo_name} + ``` + +2. Analyze the changes thoroughly + +3. Post your review using the GitHub API (GITHUB_TOKEN is already available) + +IMPORTANT: When you have completed the code review, you MUST post a summary comment +on the PR. You can use the `gh` CLI: + +```bash +gh pr comment {pr_number} --repo {repo_name} --body "## Code Review Complete + +" +``` + +Replace `` with a brief summary of your review findings. +""" + + +def run_agent_review( + prompt: str, # noqa: ARG001 - unused, cloud mode uses its own prompt + pr_info: dict[str, Any], + commit_id: str, # noqa: ARG001 - unused in cloud mode + review_style: str, +) -> None: + """Run PR review in OpenHands Cloud using OpenHandsCloudWorkspace. + + This creates a cloud sandbox, starts the review conversation, posts a + tracking comment, and exits immediately. The sandbox continues running + asynchronously with keep_alive=True. + + Note: Cloud mode uses its own prompt template (CLOUD_MODE_PROMPT) that + instructs the agent to fetch the PR diff itself. The `prompt` parameter + is ignored. + + Args: + prompt: The formatted review prompt (ignored in cloud mode) + pr_info: PR information dict with keys: number, title, body, repo_name, + base_branch, head_branch + commit_id: The HEAD commit SHA (unused in cloud mode) + review_style: Review style ('standard' or 'roasted') + """ + cloud_api_key = os.getenv("OPENHANDS_CLOUD_API_KEY") + if not cloud_api_key: + raise ValueError( + "OPENHANDS_CLOUD_API_KEY environment variable is required for cloud mode" + ) + + github_token = os.getenv("GITHUB_TOKEN") + if not github_token: + raise ValueError("GITHUB_TOKEN environment variable is required") + + cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") + + # LLM_API_KEY is optional for cloud mode - the cloud uses user's configured LLM + llm_api_key = os.getenv("LLM_API_KEY") + + # Derive skill trigger from review style + skill_trigger = ( + "/codereview" if review_style == "standard" else "/codereview-roasted" + ) + + # Create cloud-specific prompt + cloud_prompt = CLOUD_MODE_PROMPT.format( + skill_trigger=skill_trigger, + repo_name=pr_info["repo_name"], + pr_number=pr_info["number"], + title=pr_info["title"], + body=pr_info["body"] or "No description provided", + base_branch=pr_info["base_branch"], + head_branch=pr_info["head_branch"], + ) + + logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") + logger.info(f"Using skill trigger: {skill_trigger}") + + # Create LLM using shared utility (api_key is optional for cloud mode) + llm = create_llm(api_key=llm_api_key) + + # Create cloud workspace with keep_alive=True so the sandbox continues + # running after we exit + with OpenHandsCloudWorkspace( + cloud_api_url=cloud_api_url, + cloud_api_key=cloud_api_key, + keep_alive=True, + ) as workspace: + # Create agent using shared utility + agent = create_agent(llm=llm) + + # Build secrets dict - only include LLM_API_KEY if provided + secrets: dict[str, str] = {"GITHUB_TOKEN": github_token} + if llm_api_key: + secrets["LLM_API_KEY"] = llm_api_key + + # Create conversation using shared utility + conversation = create_conversation( + agent=agent, workspace=workspace, secrets=secrets + ) + + # Get conversation ID and construct URL + conversation_id = str(conversation.id) + conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" + + logger.info(f"Cloud conversation started: {conversation_id}") + + # Post comment with tracking URL + comment_body = ( + f"🤖 **OpenHands PR Review Started**\n\n" + f"The code review is running in OpenHands Cloud.\n\n" + f"📍 **Track progress:** [{conversation_url}]({conversation_url})\n\n" + f"The agent will post review comments when the analysis is complete." + ) + _post_github_comment(pr_info["repo_name"], pr_info["number"], comment_body) + + # Send message and run non-blocking - with keep_alive=True, the cloud + # sandbox continues running the review asynchronously + conversation.send_message(cloud_prompt) + # RemoteConversation.run() supports blocking parameter + if isinstance(conversation, RemoteConversation): + conversation.run(blocking=False) + else: + conversation.run() + logger.info(f"Cloud review started (non-blocking): {conversation_url}") + + +def _post_github_comment(repo_name: str, pr_number: str, body: str) -> None: + """Post a comment on a GitHub PR.""" + token = os.getenv("GITHUB_TOKEN") + if not token: + raise ValueError("GITHUB_TOKEN environment variable is required") + + url = f"https://api.github.com/repos/{repo_name}/issues/{pr_number}/comments" + + data = json.dumps({"body": body}).encode("utf-8") + + request = urllib.request.Request(url, data=data, method="POST") + request.add_header("Accept", "application/vnd.github.v3+json") + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Content-Type", "application/json") + request.add_header("X-GitHub-Api-Version", "2022-11-28") + + try: + with urllib.request.urlopen(request, timeout=60) as response: + response.read() + logger.info(f"Posted comment to PR #{pr_number}") + except urllib.error.HTTPError as e: + details = (e.read() or b"").decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"GitHub API request failed: HTTP {e.code} {e.reason}. {details}" + ) from e + except urllib.error.URLError as e: + raise RuntimeError(f"GitHub API request failed: {e.reason}") from e diff --git a/examples/03_github_workflows/02_pr_review/utils/sdk_mode.py b/examples/03_github_workflows/02_pr_review/utils/sdk_mode.py new file mode 100644 index 0000000000..008da3f666 --- /dev/null +++ b/examples/03_github_workflows/02_pr_review/utils/sdk_mode.py @@ -0,0 +1,76 @@ +"""SDK Mode - Run PR review locally using the OpenHands SDK. + +This module provides the SDK implementation for PR review, running the agent +locally with full control over the LLM configuration. +""" + +from __future__ import annotations + +import os +from typing import Any + +from openhands.sdk import get_logger +from openhands.sdk.conversation import get_agent_final_response + +from .agent_util import ( + create_agent, + create_conversation, + create_llm, + print_cost_summary, + save_laminar_trace, +) + + +logger = get_logger(__name__) + + +def run_agent_review( + prompt: str, + pr_info: dict[str, Any], + commit_id: str, + review_style: str, +) -> None: + """Run PR review using the SDK (local execution). + + Args: + prompt: The formatted review prompt + pr_info: PR information dict with keys: number, title, body, repo_name, + base_branch, head_branch + commit_id: The HEAD commit SHA + review_style: Review style ('standard' or 'roasted') + """ + api_key = os.getenv("LLM_API_KEY") + if not api_key: + raise ValueError("LLM_API_KEY environment variable is required for SDK mode") + + github_token = os.getenv("GITHUB_TOKEN") + if not github_token: + raise ValueError("GITHUB_TOKEN environment variable is required") + + # Create LLM and agent using shared utilities + llm = create_llm(api_key=api_key) + cwd = os.getcwd() + agent = create_agent(llm=llm, workspace_path=cwd) + + # Create conversation with secrets for masking + secrets: dict[str, str] = {"LLM_API_KEY": api_key, "GITHUB_TOKEN": github_token} + conversation = create_conversation(agent=agent, workspace=cwd, secrets=secrets) + + logger.info("Starting PR review analysis...") + logger.info("Agent received the PR diff in the initial message") + logger.info("Agent will post inline review comments directly via GitHub API") + + # Send message and run the conversation (blocking for local) + conversation.send_message(prompt) + conversation.run() + + # Get final response + response = get_agent_final_response(conversation.state.events) + if response: + logger.info(f"Agent final response: {len(response)} characters") + + # Print cost summary and save trace + print_cost_summary(conversation) + save_laminar_trace(pr_info, commit_id, review_style) + + logger.info("PR review completed successfully") diff --git a/examples/03_github_workflows/02_pr_review/workflow.yml b/examples/03_github_workflows/02_pr_review/workflow.yml index dce39e3236..a8bf67b557 100644 --- a/examples/03_github_workflows/02_pr_review/workflow.yml +++ b/examples/03_github_workflows/02_pr_review/workflow.yml @@ -3,7 +3,9 @@ # # To set this up: # 1. Copy this file to .github/workflows/pr-review.yml in your repository -# 2. Add LLM_API_KEY to repository secrets +# 2. Add required secrets to your repository: +# - For 'sdk' mode: LLM_API_KEY +# - For 'cloud' mode: OPENHANDS_CLOUD_API_KEY # 3. Customize the inputs below as needed # 4. Commit this file to your repository # 5. Trigger the review by either: @@ -43,13 +45,18 @@ jobs: - name: Run PR Review uses: ./.github/actions/pr-review with: - # LLM configuration + # Review mode: 'sdk' (run locally) or 'cloud' (launch in OpenHands Cloud) + mode: sdk + # LLM configuration (only used in 'sdk' mode, ignored in 'cloud' mode) llm-model: anthropic/claude-sonnet-4-5-20250929 llm-base-url: '' # Review style: roasted (other option: standard) review-style: roasted # SDK version to use (version tag or branch name) sdk-version: main - # Secrets + # Secrets for 'sdk' mode llm-api-key: ${{ secrets.LLM_API_KEY }} github-token: ${{ secrets.GITHUB_TOKEN }} + # Secrets for 'cloud' mode (uncomment and set mode: cloud to use) + # openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} + # openhands-cloud-api-url: https://app.all-hands.dev diff --git a/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py new file mode 100644 index 0000000000..c0e6e6f3a6 --- /dev/null +++ b/tests/github_workflows/test_pr_review_agent.py @@ -0,0 +1,276 @@ +"""Tests for PR review agent script. + +Note: This test file uses sys.path manipulation to import agent_script from the +examples directory. The pyright "reportMissingImports" errors are expected and +suppressed with type: ignore comments. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +# Add the PR review example directory to the path for imports +pr_review_path = ( + Path(__file__).parent.parent.parent + / "examples" + / "03_github_workflows" + / "02_pr_review" +) +sys.path.insert(0, str(pr_review_path)) + + +class TestPostGithubComment: + """Tests for the _post_github_comment function in cloud_mode.""" + + def test_success(self): + """Test successful comment posting.""" + from utils.cloud_mode import ( # type: ignore[import-not-found] + _post_github_comment, + ) + + mock_response = MagicMock() + mock_response.read.return_value = b'{"id": 1}' + + with ( + patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}, clear=False), + patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen, + ): + _post_github_comment("owner/repo", "123", "Test comment body") + + mock_urlopen.assert_called_once() + + def test_missing_token_raises_error(self): + """Test that missing GITHUB_TOKEN raises ValueError.""" + from utils.cloud_mode import ( # type: ignore[import-not-found] + _post_github_comment, + ) + + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(ValueError, match="GITHUB_TOKEN"), + ): + _post_github_comment("owner/repo", "123", "Test comment") + + +class TestGetPrDiffViaGithubApi: + """Tests for the get_pr_diff_via_github_api function.""" + + def test_success(self): + """Test successful diff fetching.""" + from agent_script import ( # type: ignore[import-not-found] + get_pr_diff_via_github_api, + ) + + mock_response = MagicMock() + mock_response.read.return_value = b"diff --git a/file.py b/file.py\n+new line" + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + env = {"REPO_NAME": "owner/repo", "GITHUB_TOKEN": "test-token"} + + with ( + patch.dict("os.environ", env, clear=False), + patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen, + ): + result = get_pr_diff_via_github_api("123") + + assert result == "diff --git a/file.py b/file.py\n+new line" + mock_urlopen.assert_called_once() + + +class TestRunCloudMode: + """Tests for run_agent_review in cloud_mode using OpenHandsCloudWorkspace.""" + + def test_cloud_mode_does_not_require_llm_api_key(self): + """Test that cloud mode does NOT require LLM_API_KEY (uses cloud's LLM).""" + from agent_script import ( # type: ignore[import-not-found] + _get_required_vars_for_mode, + ) + + vars = _get_required_vars_for_mode("cloud") + assert "LLM_API_KEY" not in vars + assert "OPENHANDS_CLOUD_API_KEY" in vars + + +class TestCloudModePrompt: + """Tests for the CLOUD_MODE_PROMPT template in cloud_mode.""" + + def test_format_with_all_fields(self): + """Test that CLOUD_MODE_PROMPT formats correctly with all fields.""" + from utils.cloud_mode import ( # type: ignore[import-not-found] + CLOUD_MODE_PROMPT, + ) + + formatted = CLOUD_MODE_PROMPT.format( + skill_trigger="/codereview", + repo_name="owner/repo", + pr_number="123", + title="Test PR", + body="Test body", + base_branch="main", + head_branch="feature", + ) + + assert "/codereview" in formatted + assert "owner/repo" in formatted + assert "123" in formatted + assert "Test PR" in formatted + assert "gh pr diff" in formatted + + +class TestGetRequiredVarsForMode: + """Tests for the _get_required_vars_for_mode function.""" + + def test_sdk_mode_requires_llm_api_key(self): + """Test that SDK mode requires LLM_API_KEY.""" + from agent_script import ( # type: ignore[import-not-found] + _get_required_vars_for_mode, + ) + + vars = _get_required_vars_for_mode("sdk") + assert "LLM_API_KEY" in vars + assert "OPENHANDS_CLOUD_API_KEY" not in vars + + def test_cloud_mode_requires_only_cloud_api_key(self): + """Test that cloud mode requires OPENHANDS_CLOUD_API_KEY but not LLM_API_KEY.""" + from agent_script import ( # type: ignore[import-not-found] + _get_required_vars_for_mode, + ) + + vars = _get_required_vars_for_mode("cloud") + assert "OPENHANDS_CLOUD_API_KEY" in vars + # Cloud mode uses the user's LLM config from OpenHands Cloud, + # so LLM_API_KEY is optional + assert "LLM_API_KEY" not in vars + + def test_both_modes_require_github_token(self): + """Test that both modes require GITHUB_TOKEN.""" + from agent_script import ( # type: ignore[import-not-found] + _get_required_vars_for_mode, + ) + + sdk_vars = _get_required_vars_for_mode("sdk") + cloud_vars = _get_required_vars_for_mode("cloud") + + assert "GITHUB_TOKEN" in sdk_vars + assert "GITHUB_TOKEN" in cloud_vars + + +class TestGetPrInfo: + """Tests for the _get_pr_info function.""" + + def test_returns_pr_info_from_env(self): + """Test that _get_pr_info returns PRInfo from environment.""" + from agent_script import _get_pr_info # type: ignore[import-not-found] + + env = { + "PR_NUMBER": "42", + "PR_TITLE": "Fix bug", + "PR_BODY": "This fixes the bug", + "REPO_NAME": "owner/repo", + "PR_BASE_BRANCH": "main", + "PR_HEAD_BRANCH": "fix-branch", + } + + with patch.dict("os.environ", env, clear=False): + pr_info = _get_pr_info() + + assert pr_info["number"] == "42" + assert pr_info["title"] == "Fix bug" + assert pr_info["body"] == "This fixes the bug" + assert pr_info["repo_name"] == "owner/repo" + assert pr_info["base_branch"] == "main" + assert pr_info["head_branch"] == "fix-branch" + + +class TestMainValidation: + """Tests for main() environment validation.""" + + def test_sdk_mode_fails_without_llm_api_key(self): + """Test that SDK mode fails without LLM_API_KEY.""" + from agent_script import main # type: ignore[import-not-found] + + env = { + "MODE": "sdk", + "GITHUB_TOKEN": "test-token", + "PR_NUMBER": "123", + "PR_TITLE": "Test PR", + "PR_BASE_BRANCH": "main", + "PR_HEAD_BRANCH": "feature", + "REPO_NAME": "owner/repo", + } + + with ( + patch.dict("os.environ", env, clear=True), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 1 + + def test_cloud_mode_fails_without_cloud_api_key(self): + """Test that cloud mode fails without OPENHANDS_CLOUD_API_KEY.""" + from agent_script import main # type: ignore[import-not-found] + + # Note: LLM_API_KEY is optional for cloud mode, so we don't include it + env = { + "MODE": "cloud", + "GITHUB_TOKEN": "test-token", + "PR_NUMBER": "123", + "PR_TITLE": "Test PR", + "PR_BASE_BRANCH": "main", + "PR_HEAD_BRANCH": "feature", + "REPO_NAME": "owner/repo", + } + + with ( + patch.dict("os.environ", env, clear=True), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 1 + + def test_both_modes_fail_without_github_token(self): + """Test that both modes fail without GITHUB_TOKEN.""" + from agent_script import main # type: ignore[import-not-found] + + sdk_env = { + "MODE": "sdk", + "LLM_API_KEY": "test-key", + "PR_NUMBER": "123", + "PR_TITLE": "Test PR", + "PR_BASE_BRANCH": "main", + "PR_HEAD_BRANCH": "feature", + "REPO_NAME": "owner/repo", + } + + with ( + patch.dict("os.environ", sdk_env, clear=True), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 1 + + # Note: LLM_API_KEY is optional for cloud mode + cloud_env = { + "MODE": "cloud", + "OPENHANDS_CLOUD_API_KEY": "test-cloud-key", + "PR_NUMBER": "123", + "PR_TITLE": "Test PR", + "PR_BASE_BRANCH": "main", + "PR_HEAD_BRANCH": "feature", + "REPO_NAME": "owner/repo", + } + + with ( + patch.dict("os.environ", cloud_env, clear=True), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 1