From e636cdbee36909e293ee63d1ef4f5edbef059342 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 8 Feb 2026 15:03:47 +0000 Subject: [PATCH 01/19] feat: Add cloud mode for PR review workflow This adds a new 'cloud' mode to the PR review workflow that launches reviews in OpenHands Cloud instead of running locally in GitHub Actions. Changes: - Add MODE environment variable (sdk/cloud) to agent_script.py - Add run_cloud_mode() function that: - Calls OpenHands Cloud API to start a conversation - Posts a comment on the PR with the cloud conversation URL - Exits without monitoring (review continues asynchronously) - Add post_github_comment() helper function - Add _start_cloud_conversation() helper function - Update composite action.yml with new inputs: - mode (sdk/cloud) - openhands-cloud-api-key - openhands-cloud-api-url - Update workflow.yml example to support cloud mode - Update README.md with cloud mode documentation - Add tests for the new functionality Cloud mode benefits: - Faster CI completion (exits after starting the review) - Track review progress in OpenHands Cloud UI - Interact with the review conversation - Uses the LLM model configured in OpenHands Cloud account Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 66 +++- .../02_pr_review/README.md | 55 +++- .../02_pr_review/agent_script.py | 306 +++++++++++++++--- .../02_pr_review/workflow.yml | 13 +- .../github_workflows/test_pr_review_agent.py | 250 ++++++++++++++ 5 files changed, 616 insertions(+), 74 deletions(-) create mode 100644 tests/github_workflows/test_pr_review_agent.py diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index e504337527..0dd43374e3 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -8,12 +8,16 @@ branding: color: blue inputs: + mode: + description: "Review mode: 'sdk' (run locally in GitHub Actions) or 'cloud' (launch in OpenHands Cloud and exit)" + required: false + default: sdk llm-model: - description: LLM model to use for the review + description: LLM model to use for the review (only used in 'sdk' mode, ignored in 'cloud' mode) 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 (optional, for custom LLM endpoints, only used in 'sdk' mode) required: false default: '' review-style: @@ -30,11 +34,21 @@ inputs: required: false default: main llm-api-key: - description: LLM API key (required) - required: true + description: LLM API key (required for 'sdk' mode, not required for 'cloud' mode) + required: false + default: '' github-token: - description: GitHub token for API access (required) - required: true + description: GitHub token for API access (required for 'sdk' mode, optional for 'cloud' mode if OpenHands Cloud account has repo access) + required: false + default: '' + openhands-cloud-api-key: + description: OpenHands Cloud API key (required for 'cloud' mode) + required: false + default: '' + openhands-cloud-api-url: + description: OpenHands Cloud API URL + required: false + default: https://app.all-hands.dev runs: using: composite @@ -79,15 +93,29 @@ runs: - 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." - exit 1 - fi - if [ -z "$GITHUB_TOKEN" ]; then - echo "Error: github-token is required." + echo "Mode: $MODE" + + if [ "$MODE" = "sdk" ]; then + if [ -z "$LLM_API_KEY" ]; then + echo "Error: llm-api-key is required for 'sdk' mode." + exit 1 + fi + if [ -z "$GITHUB_TOKEN" ]; then + echo "Error: github-token is required for 'sdk' mode." + exit 1 + fi + elif [ "$MODE" = "cloud" ]; then + if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then + echo "Error: openhands-cloud-api-key is required for 'cloud' mode." + exit 1 + fi + else + echo "Error: mode must be 'sdk' or 'cloud', got '$MODE'." exit 1 fi @@ -95,19 +123,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 model 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 }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index a5a5d3b85f..66c40a7b08 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -12,6 +12,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 @@ -47,21 +50,30 @@ 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) -**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](https://app.all-hands.dev) + +**Note**: The workflow automatically uses the `GITHUB_TOKEN` secret that's available in all GitHub Actions workflows. For cloud mode, you may not need a GitHub token if your OpenHands Cloud account already has access to the repository. ### 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: '' @@ -76,6 +88,32 @@ 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' launches the review in OpenHands Cloud + mode: cloud + # Review style: roasted (other option: standard) + review-style: roasted + # SDK git ref to use + sdk-version: main + # Secrets for cloud mode + openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} + # Optional: custom cloud API URL + # openhands-cloud-api-url: https://app.all-hands.dev + # Optional: GitHub token for posting comments (may not be needed if cloud has repo access) + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +**Cloud Mode Benefits:** +- Faster CI completion (exits after starting the review) +- Track review progress in OpenHands Cloud UI +- Interact with the review conversation +- Uses the LLM model configured in your OpenHands Cloud account + ### 4. Create the review label Create a `review-this` label in your repository: @@ -177,10 +215,13 @@ This workflow uses a reusable composite action located at `.github/actions/pr-re | Input | Description | Required | Default | |-------|-------------|----------|---------| -| `llm-model` | LLM model to use | No | `anthropic/claude-sonnet-4-5-20250929` | -| `llm-base-url` | LLM base URL (optional) | No | `''` | +| `mode` | Review mode: 'sdk' or 'cloud' | No | `sdk` | +| `llm-model` | LLM model (sdk mode only) | No | `anthropic/claude-sonnet-4-5-20250929` | +| `llm-base-url` | LLM base URL (sdk mode only) | 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 | - | -| `github-token` | GitHub token for API access | Yes | - | +| `llm-api-key` | LLM API key (required for sdk mode) | No | - | +| `github-token` | GitHub token (required for sdk mode) | No | - | +| `openhands-cloud-api-key` | OpenHands Cloud API key (required for cloud mode) | No | - | +| `openhands-cloud-api-url` | OpenHands Cloud API URL | No | `https://app.all-hands.dev` | 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 bc93c181e2..c7ddcba534 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -17,9 +17,12 @@ Designed for use with GitHub Actions workflows triggered by PR labels. Environment Variables: - LLM_API_KEY: API key for the LLM (required) - LLM_MODEL: Language model to use (default: anthropic/claude-sonnet-4-5-20250929) - LLM_BASE_URL: Optional base URL for LLM API + MODE: Review mode ('sdk' or 'cloud', default: 'sdk') + - 'sdk': Run the agent locally using the SDK (default) + - 'cloud': Launch a review task in OpenHands Cloud and exit + LLM_API_KEY: API key for the LLM (required for 'sdk' mode, ignored in 'cloud' mode) + LLM_MODEL: Language model to use (required for 'sdk' mode, ignored in 'cloud' mode) + LLM_BASE_URL: Optional base URL for LLM API (only used in 'sdk' mode) GITHUB_TOKEN: GitHub token for API access (required) PR_NUMBER: Pull request number (required) PR_TITLE: Pull request title (required) @@ -28,11 +31,17 @@ 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) + +Note: In 'cloud' mode, LLM_MODEL and LLM_BASE_URL are ignored. The reviewer bot +will use the model configured in your OpenHands Cloud account. For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. """ +import json import os import sys import urllib.error @@ -138,46 +147,195 @@ 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...") +def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: + """Post a comment on a GitHub PR. - # Validate required environment variables - required_vars = [ - "LLM_API_KEY", - "GITHUB_TOKEN", - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] + Args: + repo_name: Repository name in format owner/repo + pr_number: Pull request number + body: Comment body text + """ + token = _get_required_env("GITHUB_TOKEN") + url = f"https://api.github.com/repos/{repo_name}/issues/{pr_number}/comments" - 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) + 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") - github_token = os.getenv("GITHUB_TOKEN") + try: + with urllib.request.urlopen(request, timeout=60) as response: + logger.info(f"Posted comment to PR #{pr_number}: {response.status}") + except urllib.error.HTTPError as e: + details = (e.read() or b"").decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"GitHub comment API request failed: HTTP {e.code} {e.reason}. {details}" + ) from e + except urllib.error.URLError as e: + raise RuntimeError(f"GitHub comment API request failed: {e.reason}") from e - # 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"), + +def _start_cloud_conversation( + cloud_api_url: str, + cloud_api_key: str, + prompt: str, + github_token: str | None = None, +) -> str: + """Start a conversation in OpenHands Cloud. + + Args: + cloud_api_url: OpenHands Cloud API URL + cloud_api_key: OpenHands Cloud API key + prompt: The initial prompt for the conversation + github_token: Optional GitHub token to pass as a secret + + Returns: + The conversation ID + """ + url = f"{cloud_api_url}/api/conversations" + + # Build the request payload + payload: dict = { + "initial_user_msg": prompt, } - # 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" + # Add secrets if provided + if github_token: + payload["secrets"] = {"GITHUB_TOKEN": github_token} - logger.info(f"Reviewing PR #{pr_info['number']}: {pr_info['title']}") - logger.info(f"Review style: {review_style}") + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=data, method="POST") + request.add_header("Authorization", f"Bearer {cloud_api_key}") + request.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(request, timeout=60) as response: + result = json.loads(response.read().decode("utf-8")) + conversation_id = result.get("conversation_id") or result.get("id") + if not conversation_id: + raise RuntimeError( + f"Cloud API response missing conversation_id: {result}" + ) + logger.info(f"Created cloud conversation: {conversation_id}") + return conversation_id + except urllib.error.HTTPError as e: + details = (e.read() or b"").decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"Cloud API request failed: HTTP {e.code} {e.reason}. {details}" + ) from e + except urllib.error.URLError as e: + raise RuntimeError(f"Cloud API request failed: {e.reason}") from e + + +def run_cloud_mode(pr_info: dict, review_style: str) -> None: + """Run the PR review in OpenHands Cloud mode. + + This mode: + 1. Creates an OpenHands Cloud conversation + 2. Sends the review prompt to the cloud + 3. Posts a comment on the PR with the cloud conversation URL + 4. Exits without monitoring the conversation + + Note: In cloud mode, LLM_MODEL and LLM_BASE_URL are ignored. The reviewer + bot will use the model configured in your OpenHands Cloud account. + + Args: + pr_info: Dictionary containing PR information + review_style: Review style ('standard' or 'roasted') + """ + # Get cloud-specific configuration + cloud_api_key = os.getenv("OPENHANDS_CLOUD_API_KEY") + if not cloud_api_key: + logger.error("OPENHANDS_CLOUD_API_KEY is required for 'cloud' mode") + sys.exit(1) + + cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") + logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") + logger.info( + "Note: LLM_MODEL and LLM_BASE_URL are ignored in cloud mode. " + "The model configured in your OpenHands Cloud account will be used." + ) + + try: + 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}") + + # Create the review prompt + skill_trigger = ( + "/codereview" if review_style == "standard" else "/codereview-roasted" + ) + prompt = PROMPT.format( + title=pr_info.get("title", "N/A"), + body=pr_info.get("body", "No description provided"), + repo_name=pr_info.get("repo_name", "N/A"), + base_branch=pr_info.get("base_branch", "main"), + head_branch=pr_info.get("head_branch", "N/A"), + pr_number=pr_info.get("number", "N/A"), + commit_id=commit_id, + skill_trigger=skill_trigger, + diff=pr_diff, + ) + + logger.info("Starting OpenHands Cloud conversation...") + + # Use the cloud API to start a conversation + # The cloud will use the model configured in the user's account + github_token = os.getenv("GITHUB_TOKEN") + + # Create conversation via cloud API + conversation_id = _start_cloud_conversation( + cloud_api_url=cloud_api_url, + cloud_api_key=cloud_api_key, + prompt=prompt, + github_token=github_token, + ) + + # Build the cloud conversation URL + conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" + logger.info(f"Cloud conversation URL: {conversation_url}") + + logger.info("Review task started in OpenHands Cloud") + + # Post a comment on the PR with the cloud URL + comment_body = ( + f"🤖 **OpenHands PR Review Started**\n\n" + f"A code review has been initiated in OpenHands Cloud.\n\n" + f"📍 **Track progress here:** [{conversation_url}]({conversation_url})\n\n" + f"The review will analyze the changes and post inline comments " + f"directly on this PR when complete." + ) + + post_github_comment( + repo_name=pr_info["repo_name"], + pr_number=pr_info["number"], + body=comment_body, + ) + + logger.info("Posted cloud review notification comment to PR") + logger.info("Exiting - review will continue in OpenHands Cloud") + + except Exception as e: + logger.error(f"Cloud mode PR review failed: {e}") + sys.exit(1) + + +def run_sdk_mode(pr_info: dict, review_style: str) -> None: + """Run the PR review in SDK mode (local execution). + + This is the original behavior - runs the agent locally and monitors + until completion. + + Args: + pr_info: Dictionary containing PR information + review_style: Review style ('standard' or 'roasted') + """ + github_token = os.getenv("GITHUB_TOKEN") try: pr_diff = get_truncated_pr_diff() @@ -188,7 +346,6 @@ def main(): logger.info(f"HEAD commit SHA: {commit_id}") # 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" ) @@ -225,18 +382,12 @@ def main(): cwd = os.getcwd() # Create AgentContext with public skills enabled - # This loads skills from https://github.com/OpenHands/skills including: - # - /codereview: Standard code review skill - # - /codereview-roasted: Linus Torvalds style brutally honest review - agent_context = AgentContext( - load_public_skills=True, - ) + agent_context = AgentContext(load_public_skills=True) # 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 + tools=get_default_tools(enable_browser=False), agent_context=agent_context, system_prompt_kwargs={"cli_mode": True}, condenser=get_default_condenser( @@ -245,7 +396,6 @@ def main(): ) # 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 @@ -264,12 +414,9 @@ def main(): 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: @@ -295,5 +442,66 @@ def main(): sys.exit(1) +def main(): + """Run the PR review agent.""" + logger.info("Starting PR review process...") + + # Get mode + 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}") + + # Validate required environment variables based on mode + base_required_vars = [ + "GITHUB_TOKEN", + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] + + if mode == "sdk": + # SDK mode requires LLM_API_KEY for local LLM access + required_vars = base_required_vars + ["LLM_API_KEY"] + else: # cloud mode + # Cloud mode only requires OPENHANDS_CLOUD_API_KEY + # LLM_MODEL and LLM_BASE_URL are ignored - cloud uses its own model + required_vars = base_required_vars + ["OPENHANDS_CLOUD_API_KEY"] + + 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) + + # 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"), + } + + # 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}") + + # Run the appropriate mode + if mode == "cloud": + run_cloud_mode(pr_info, review_style) + else: + run_sdk_mode(pr_info, review_style) + + if __name__ == "__main__": main() 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..0a00e18bfb --- /dev/null +++ b/tests/github_workflows/test_pr_review_agent.py @@ -0,0 +1,250 @@ +"""Tests for PR review agent script.""" + +import json +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)) + + +def test_post_github_comment_success(): + """Test successful comment posting.""" + from agent_script import post_github_comment # type: ignore[import-not-found] + + mock_response = MagicMock() + mock_response.status = 201 + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + 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") + + # Verify the request was made + mock_urlopen.assert_called_once() + call_args = mock_urlopen.call_args + request = call_args[0][0] + + assert request.full_url == ( + "https://api.github.com/repos/owner/repo/issues/123/comments" + ) + assert request.get_header("Authorization") == "Bearer test-token" + assert request.get_header("Content-type") == "application/json" + + +def test_post_github_comment_missing_token(): + """Test that missing GITHUB_TOKEN raises error.""" + from agent_script import post_github_comment # type: ignore[import-not-found] + + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(ValueError, match="GITHUB_TOKEN"), + ): + post_github_comment("owner/repo", "123", "Test comment") + + +def test_start_cloud_conversation_success(): + """Test successful cloud conversation creation.""" + from agent_script import ( # type: ignore[import-not-found] + _start_cloud_conversation, + ) + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps( + {"conversation_id": "test-conv-123"} + ).encode("utf-8") + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen: + result = _start_cloud_conversation( + cloud_api_url="https://app.all-hands.dev", + cloud_api_key="test-cloud-key", + prompt="Test prompt", + github_token="test-github-token", + ) + + assert result == "test-conv-123" + mock_urlopen.assert_called_once() + call_args = mock_urlopen.call_args + request = call_args[0][0] + + assert request.full_url == "https://app.all-hands.dev/api/conversations" + assert request.get_header("Authorization") == "Bearer test-cloud-key" + assert request.get_header("Content-type") == "application/json" + + +def test_start_cloud_conversation_with_id_field(): + """Test cloud conversation handles 'id' field in response.""" + from agent_script import ( # type: ignore[import-not-found] + _start_cloud_conversation, + ) + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"id": "conv-456"}).encode("utf-8") + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_response): + result = _start_cloud_conversation( + cloud_api_url="https://app.all-hands.dev", + cloud_api_key="test-key", + prompt="Test", + ) + + assert result == "conv-456" + + +def test_start_cloud_conversation_missing_id(): + """Test cloud conversation raises error when response missing conversation_id.""" + from agent_script import ( # type: ignore[import-not-found] + _start_cloud_conversation, + ) + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"status": "ok"}).encode("utf-8") + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + with ( + patch("urllib.request.urlopen", return_value=mock_response), + pytest.raises(RuntimeError, match="missing conversation_id"), + ): + _start_cloud_conversation( + cloud_api_url="https://app.all-hands.dev", + cloud_api_key="test-key", + prompt="Test", + ) + + +def test_mode_defaults_to_sdk(): + """Test that MODE defaults to 'sdk'.""" + import os + + # When MODE is not set, it should default to 'sdk' + with patch.dict("os.environ", {}, clear=False): + mode = os.getenv("MODE", "sdk").lower() + assert mode == "sdk" + + +def test_mode_cloud_accepted(): + """Test that 'cloud' is a valid MODE.""" + import os + + with patch.dict("os.environ", {"MODE": "cloud"}, clear=False): + mode = os.getenv("MODE", "sdk").lower() + assert mode == "cloud" + + +def test_mode_case_insensitive(): + """Test that MODE is case insensitive.""" + import os + + for value in ["CLOUD", "Cloud", "cLoUd"]: + with patch.dict("os.environ", {"MODE": value}, clear=False): + mode = os.getenv("MODE", "sdk").lower() + assert mode == "cloud" + + +def test_sdk_mode_requires_llm_api_key(): + """Test that SDK mode requires LLM_API_KEY.""" + base_required_vars = [ + "GITHUB_TOKEN", + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] + sdk_required_vars = base_required_vars + ["LLM_API_KEY"] + + assert "LLM_API_KEY" in sdk_required_vars + assert "OPENHANDS_CLOUD_API_KEY" not in sdk_required_vars + + +def test_cloud_mode_requires_cloud_api_key(): + """Test that cloud mode requires OPENHANDS_CLOUD_API_KEY.""" + base_required_vars = [ + "GITHUB_TOKEN", + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] + cloud_required_vars = base_required_vars + ["OPENHANDS_CLOUD_API_KEY"] + + assert "OPENHANDS_CLOUD_API_KEY" in cloud_required_vars + # Cloud mode does NOT require LLM_API_KEY + assert "LLM_API_KEY" not in cloud_required_vars + + +def test_conversation_url_format(): + """Test that conversation URL is correctly formatted.""" + cloud_api_url = "https://app.all-hands.dev" + conversation_id = "12345678-1234-1234-1234-123456789abc" + + expected_url = f"{cloud_api_url}/conversations/{conversation_id}" + assert expected_url == ( + "https://app.all-hands.dev/conversations/12345678-1234-1234-1234-123456789abc" + ) + + +def test_conversation_url_with_custom_api_url(): + """Test conversation URL with custom cloud API URL.""" + cloud_api_url = "https://custom.openhands.dev" + conversation_id = "test-conversation-id" + + expected_url = f"{cloud_api_url}/conversations/{conversation_id}" + assert expected_url == ( + "https://custom.openhands.dev/conversations/test-conversation-id" + ) + + +def test_comment_body_contains_url(): + """Test that comment body contains the conversation URL.""" + conversation_url = "https://app.all-hands.dev/conversations/test-id" + + comment_body = ( + f"🤖 **OpenHands PR Review Started**\n\n" + f"A code review has been initiated in OpenHands Cloud.\n\n" + f"📍 **Track progress here:** [{conversation_url}]({conversation_url})\n\n" + f"The review will analyze the changes and post inline comments " + f"directly on this PR when complete." + ) + + assert conversation_url in comment_body + assert "OpenHands PR Review Started" in comment_body + assert "Track progress here" in comment_body + + +def test_comment_body_is_markdown(): + """Test that comment body uses markdown formatting.""" + conversation_url = "https://app.all-hands.dev/conversations/test-id" + + comment_body = ( + f"🤖 **OpenHands PR Review Started**\n\n" + f"A code review has been initiated in OpenHands Cloud.\n\n" + f"📍 **Track progress here:** [{conversation_url}]({conversation_url})\n\n" + f"The review will analyze the changes and post inline comments " + f"directly on this PR when complete." + ) + + # Check for markdown bold syntax + assert "**OpenHands PR Review Started**" in comment_body + assert "**Track progress here:**" in comment_body + # Check for markdown link syntax + assert f"[{conversation_url}]({conversation_url})" in comment_body From b457d09e11ccf260c5c412ce02660467755018d0 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sun, 8 Feb 2026 23:44:35 +0800 Subject: [PATCH 02/19] Apply suggestion from @xingyaoww --- .github/actions/pr-review/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 0dd43374e3..3e1a2248ca 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -42,7 +42,7 @@ inputs: required: false default: '' openhands-cloud-api-key: - description: OpenHands Cloud API key (required for 'cloud' mode) + 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: From 158d42868a01f5eb1ffdd023f0d6f7ff1f52f0c4 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 8 Feb 2026 16:03:02 +0000 Subject: [PATCH 03/19] refactor: Address code review feedback for PR review cloud mode - Extract shared setup code into _prepare_review_context() helper function to eliminate code duplication between run_cloud_mode() and run_sdk_mode() - Fix inconsistent GITHUB_TOKEN requirements: update action.yml to correctly document that GITHUB_TOKEN is required for both modes (used to post PR comments) - Remove redundant OPENHANDS_CLOUD_API_KEY validation in run_cloud_mode() (already validated in main()) - Extract comment body into CLOUD_REVIEW_COMMENT_TEMPLATE constant - Improve tests to actually test behavior by calling main() instead of just verifying list membership - Add test for _prepare_review_context() helper function --- .github/actions/pr-review/action.yml | 12 +- .../02_pr_review/agent_script.py | 122 ++++++------ .../github_workflows/test_pr_review_agent.py | 184 ++++++++++++++---- 3 files changed, 210 insertions(+), 108 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 3e1a2248ca..585a3722d9 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -38,7 +38,7 @@ inputs: required: false default: '' github-token: - description: GitHub token for API access (required for 'sdk' mode, optional for 'cloud' mode if OpenHands Cloud account has repo access) + description: GitHub token for API access (required for both modes - used to post PR comments) required: false default: '' openhands-cloud-api-key: @@ -100,15 +100,17 @@ runs: run: | echo "Mode: $MODE" + # GITHUB_TOKEN is required for both modes (used to post PR comments) + if [ -z "$GITHUB_TOKEN" ]; then + echo "Error: github-token is required for both modes." + exit 1 + fi + if [ "$MODE" = "sdk" ]; then if [ -z "$LLM_API_KEY" ]; then echo "Error: llm-api-key is required for 'sdk' mode." exit 1 fi - if [ -z "$GITHUB_TOKEN" ]; then - echo "Error: github-token is required for 'sdk' mode." - exit 1 - fi elif [ "$MODE" = "cloud" ]; then if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then echo "Error: openhands-cloud-api-key is required for 'cloud' mode." 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 c7ddcba534..1c85eb48ff 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -66,6 +66,17 @@ # Maximum total diff size MAX_TOTAL_DIFF = 100000 +# Template for cloud mode PR comment +CLOUD_REVIEW_COMMENT_TEMPLATE = """\ +🤖 **OpenHands PR Review Started** + +A code review has been initiated in OpenHands Cloud. + +📍 **Track progress here:** [{conversation_url}]({conversation_url}) + +The review will analyze the changes and post inline comments \ +directly on this PR when complete.""" + def _get_required_env(name: str) -> str: value = os.getenv(name) @@ -177,6 +188,46 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: raise RuntimeError(f"GitHub comment API request failed: {e.reason}") from e +def _prepare_review_context(pr_info: dict, review_style: str) -> tuple[str, str]: + """Prepare the review context including diff and prompt. + + This is shared setup code used by both cloud and SDK modes. + + Args: + pr_info: Dictionary containing PR information + review_style: Review style ('standard' or 'roasted') + + Returns: + Tuple of (prompt, skill_trigger) + """ + 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}") + + # Determine skill trigger based on review style + skill_trigger = ( + "/codereview" if review_style == "standard" else "/codereview-roasted" + ) + + # Create the review prompt using the template + prompt = PROMPT.format( + title=pr_info.get("title", "N/A"), + body=pr_info.get("body", "No description provided"), + repo_name=pr_info.get("repo_name", "N/A"), + base_branch=pr_info.get("base_branch", "main"), + head_branch=pr_info.get("head_branch", "N/A"), + pr_number=pr_info.get("number", "N/A"), + commit_id=commit_id, + skill_trigger=skill_trigger, + diff=pr_diff, + ) + + return prompt, skill_trigger + + def _start_cloud_conversation( cloud_api_url: str, cloud_api_key: str, @@ -245,13 +296,10 @@ def run_cloud_mode(pr_info: dict, review_style: str) -> None: pr_info: Dictionary containing PR information review_style: Review style ('standard' or 'roasted') """ - # Get cloud-specific configuration - cloud_api_key = os.getenv("OPENHANDS_CLOUD_API_KEY") - if not cloud_api_key: - logger.error("OPENHANDS_CLOUD_API_KEY is required for 'cloud' mode") - sys.exit(1) - + # Get cloud-specific configuration (already validated in main()) + cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") + logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") logger.info( "Note: LLM_MODEL and LLM_BASE_URL are ignored in cloud mode. " @@ -259,36 +307,13 @@ def run_cloud_mode(pr_info: dict, review_style: str) -> None: ) try: - 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}") - - # Create the review prompt - skill_trigger = ( - "/codereview" if review_style == "standard" else "/codereview-roasted" - ) - prompt = PROMPT.format( - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - repo_name=pr_info.get("repo_name", "N/A"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - pr_number=pr_info.get("number", "N/A"), - commit_id=commit_id, - skill_trigger=skill_trigger, - diff=pr_diff, - ) + # Prepare review context (shared with SDK mode) + prompt, _ = _prepare_review_context(pr_info, review_style) logger.info("Starting OpenHands Cloud conversation...") - # Use the cloud API to start a conversation - # The cloud will use the model configured in the user's account - github_token = os.getenv("GITHUB_TOKEN") - # Create conversation via cloud API + github_token = os.getenv("GITHUB_TOKEN") conversation_id = _start_cloud_conversation( cloud_api_url=cloud_api_url, cloud_api_key=cloud_api_key, @@ -299,16 +324,11 @@ def run_cloud_mode(pr_info: dict, review_style: str) -> None: # Build the cloud conversation URL conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" logger.info(f"Cloud conversation URL: {conversation_url}") - logger.info("Review task started in OpenHands Cloud") # Post a comment on the PR with the cloud URL - comment_body = ( - f"🤖 **OpenHands PR Review Started**\n\n" - f"A code review has been initiated in OpenHands Cloud.\n\n" - f"📍 **Track progress here:** [{conversation_url}]({conversation_url})\n\n" - f"The review will analyze the changes and post inline comments " - f"directly on this PR when complete." + comment_body = CLOUD_REVIEW_COMMENT_TEMPLATE.format( + conversation_url=conversation_url ) post_github_comment( @@ -338,28 +358,8 @@ def run_sdk_mode(pr_info: dict, review_style: str) -> None: github_token = os.getenv("GITHUB_TOKEN") try: - 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}") - - # Create the review prompt using the template - skill_trigger = ( - "/codereview" if review_style == "standard" else "/codereview-roasted" - ) - prompt = PROMPT.format( - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - repo_name=pr_info.get("repo_name", "N/A"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - pr_number=pr_info.get("number", "N/A"), - commit_id=commit_id, - skill_trigger=skill_trigger, - diff=pr_diff, - ) + # Prepare review context (shared with cloud mode) + prompt, skill_trigger = _prepare_review_context(pr_info, review_style) # Configure LLM api_key = os.getenv("LLM_API_KEY") diff --git a/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py index 0a00e18bfb..0c07956a7f 100644 --- a/tests/github_workflows/test_pr_review_agent.py +++ b/tests/github_workflows/test_pr_review_agent.py @@ -130,6 +130,46 @@ def test_start_cloud_conversation_missing_id(): ) +def test_prepare_review_context(): + """Test that _prepare_review_context returns correct prompt and skill trigger.""" + from agent_script import ( # type: ignore[import-not-found] + _prepare_review_context, + ) + + pr_info = { + "number": "123", + "title": "Test PR", + "body": "Test body", + "repo_name": "owner/repo", + "base_branch": "main", + "head_branch": "feature", + } + + # Mock the functions that _prepare_review_context calls + with ( + patch.dict( + "os.environ", + { + "GITHUB_TOKEN": "test-token", + "REPO_NAME": "owner/repo", + "PR_NUMBER": "123", + }, + clear=False, + ), + patch("agent_script.get_truncated_pr_diff", return_value="mock diff content"), + patch("agent_script.get_head_commit_sha", return_value="abc123"), + ): + # Test standard review style + prompt, skill_trigger = _prepare_review_context(pr_info, "standard") + assert skill_trigger == "/codereview" + assert "Test PR" in prompt + assert "mock diff content" in prompt + + # Test roasted review style + prompt, skill_trigger = _prepare_review_context(pr_info, "roasted") + assert skill_trigger == "/codereview-roasted" + + def test_mode_defaults_to_sdk(): """Test that MODE defaults to 'sdk'.""" import os @@ -160,36 +200,98 @@ def test_mode_case_insensitive(): def test_sdk_mode_requires_llm_api_key(): - """Test that SDK mode requires LLM_API_KEY.""" - base_required_vars = [ - "GITHUB_TOKEN", - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] - sdk_required_vars = base_required_vars + ["LLM_API_KEY"] + """Test that SDK mode fails without LLM_API_KEY.""" + from agent_script import main # type: ignore[import-not-found] + + # Set up minimal environment for SDK mode but missing LLM_API_KEY + 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", + # LLM_API_KEY intentionally missing + } - assert "LLM_API_KEY" in sdk_required_vars - assert "OPENHANDS_CLOUD_API_KEY" not in sdk_required_vars + 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_requires_cloud_api_key(): - """Test that cloud mode requires OPENHANDS_CLOUD_API_KEY.""" - base_required_vars = [ - "GITHUB_TOKEN", - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] - cloud_required_vars = base_required_vars + ["OPENHANDS_CLOUD_API_KEY"] - - assert "OPENHANDS_CLOUD_API_KEY" in cloud_required_vars - # Cloud mode does NOT require LLM_API_KEY - assert "LLM_API_KEY" not in cloud_required_vars + """Test that cloud mode fails without OPENHANDS_CLOUD_API_KEY.""" + from agent_script import main # type: ignore[import-not-found] + + # Set up minimal environment for cloud mode but missing OPENHANDS_CLOUD_API_KEY + 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", + # OPENHANDS_CLOUD_API_KEY intentionally missing + } + + 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_require_github_token(): + """Test that both modes require GITHUB_TOKEN.""" + from agent_script import main # type: ignore[import-not-found] + + # Test SDK mode without GITHUB_TOKEN + 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", + # GITHUB_TOKEN intentionally missing + } + + with ( + patch.dict("os.environ", sdk_env, clear=True), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 1 + + # Test cloud mode without GITHUB_TOKEN + cloud_env = { + "MODE": "cloud", + "OPENHANDS_CLOUD_API_KEY": "test-key", + "PR_NUMBER": "123", + "PR_TITLE": "Test PR", + "PR_BASE_BRANCH": "main", + "PR_HEAD_BRANCH": "feature", + "REPO_NAME": "owner/repo", + # GITHUB_TOKEN intentionally missing + } + + with ( + patch.dict("os.environ", cloud_env, clear=True), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 1 def test_conversation_url_format(): @@ -215,15 +317,14 @@ def test_conversation_url_with_custom_api_url(): def test_comment_body_contains_url(): - """Test that comment body contains the conversation URL.""" - conversation_url = "https://app.all-hands.dev/conversations/test-id" + """Test that comment body template contains the conversation URL.""" + from agent_script import ( # type: ignore[import-not-found] + CLOUD_REVIEW_COMMENT_TEMPLATE, + ) - comment_body = ( - f"🤖 **OpenHands PR Review Started**\n\n" - f"A code review has been initiated in OpenHands Cloud.\n\n" - f"📍 **Track progress here:** [{conversation_url}]({conversation_url})\n\n" - f"The review will analyze the changes and post inline comments " - f"directly on this PR when complete." + conversation_url = "https://app.all-hands.dev/conversations/test-id" + comment_body = CLOUD_REVIEW_COMMENT_TEMPLATE.format( + conversation_url=conversation_url ) assert conversation_url in comment_body @@ -232,15 +333,14 @@ def test_comment_body_contains_url(): def test_comment_body_is_markdown(): - """Test that comment body uses markdown formatting.""" - conversation_url = "https://app.all-hands.dev/conversations/test-id" + """Test that comment body template uses markdown formatting.""" + from agent_script import ( # type: ignore[import-not-found] + CLOUD_REVIEW_COMMENT_TEMPLATE, + ) - comment_body = ( - f"🤖 **OpenHands PR Review Started**\n\n" - f"A code review has been initiated in OpenHands Cloud.\n\n" - f"📍 **Track progress here:** [{conversation_url}]({conversation_url})\n\n" - f"The review will analyze the changes and post inline comments " - f"directly on this PR when complete." + conversation_url = "https://app.all-hands.dev/conversations/test-id" + comment_body = CLOUD_REVIEW_COMMENT_TEMPLATE.format( + conversation_url=conversation_url ) # Check for markdown bold syntax From 03cb497a2b0281734deaa1a8bb4d6399f751e89d Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 8 Feb 2026 16:25:13 +0000 Subject: [PATCH 04/19] refactor: prepare review context before calling run_*_mode functions Move _prepare_review_context call to main() and pass the prompt to both run_sdk_mode and run_cloud_mode functions. This reduces code duplication and makes the flow clearer - context preparation happens once, then the mode-specific logic runs. - run_cloud_mode now takes (pr_info, prompt) instead of (pr_info, review_style) - run_sdk_mode now takes (prompt, skill_trigger) instead of (pr_info, review_style) --- .../02_pr_review/agent_script.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) 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 1c85eb48ff..b9f36e76c6 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -280,7 +280,7 @@ def _start_cloud_conversation( raise RuntimeError(f"Cloud API request failed: {e.reason}") from e -def run_cloud_mode(pr_info: dict, review_style: str) -> None: +def run_cloud_mode(pr_info: dict, prompt: str) -> None: """Run the PR review in OpenHands Cloud mode. This mode: @@ -293,8 +293,8 @@ def run_cloud_mode(pr_info: dict, review_style: str) -> None: bot will use the model configured in your OpenHands Cloud account. Args: - pr_info: Dictionary containing PR information - review_style: Review style ('standard' or 'roasted') + pr_info: Dictionary containing PR information (repo_name, number) + prompt: The review prompt to send to the cloud """ # Get cloud-specific configuration (already validated in main()) cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") @@ -307,9 +307,6 @@ def run_cloud_mode(pr_info: dict, review_style: str) -> None: ) try: - # Prepare review context (shared with SDK mode) - prompt, _ = _prepare_review_context(pr_info, review_style) - logger.info("Starting OpenHands Cloud conversation...") # Create conversation via cloud API @@ -345,22 +342,19 @@ def run_cloud_mode(pr_info: dict, review_style: str) -> None: sys.exit(1) -def run_sdk_mode(pr_info: dict, review_style: str) -> None: +def run_sdk_mode(prompt: str, skill_trigger: str) -> None: """Run the PR review in SDK mode (local execution). This is the original behavior - runs the agent locally and monitors until completion. Args: - pr_info: Dictionary containing PR information - review_style: Review style ('standard' or 'roasted') + prompt: The review prompt to send to the agent + skill_trigger: The skill trigger to use (e.g., '/codereview') """ github_token = os.getenv("GITHUB_TOKEN") try: - # Prepare review context (shared with cloud mode) - prompt, skill_trigger = _prepare_review_context(pr_info, review_style) - # Configure LLM api_key = os.getenv("LLM_API_KEY") model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") @@ -496,11 +490,14 @@ def main(): logger.info(f"Reviewing PR #{pr_info['number']}: {pr_info['title']}") logger.info(f"Review style: {review_style}") + # Prepare review context (shared by both modes) + prompt, skill_trigger = _prepare_review_context(pr_info, review_style) + # Run the appropriate mode if mode == "cloud": - run_cloud_mode(pr_info, review_style) + run_cloud_mode(pr_info, prompt) else: - run_sdk_mode(pr_info, review_style) + run_sdk_mode(prompt, skill_trigger) if __name__ == "__main__": From 713e4808b4fd704b6a8f71ff3b07b0e957beacaf Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 8 Feb 2026 16:40:37 +0000 Subject: [PATCH 05/19] fix: Remove unsupported secrets field from Cloud API request The OpenHands Cloud API does not accept a 'secrets' field in the request payload (causes HTTP 422 'Extra inputs are not permitted' error). The Cloud API has 'additionalProperties: false' in its schema, which means it only accepts the documented fields: initial_user_msg, repository, git_provider, selected_branch, etc. Instead of passing secrets directly, OpenHands Cloud uses the user's connected GitHub account for repository access. Added an informational log message to explain this behavior when github_token is provided. Co-authored-by: openhands --- .../03_github_workflows/02_pr_review/agent_script.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 b9f36e76c6..136d35fa35 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -248,13 +248,18 @@ def _start_cloud_conversation( url = f"{cloud_api_url}/api/conversations" # Build the request payload + # Note: The OpenHands Cloud API does not accept secrets directly. + # Instead, it uses the user's connected GitHub account for repository access. + # The github_token is available in the agent's environment for API calls. payload: dict = { "initial_user_msg": prompt, } - # Add secrets if provided if github_token: - payload["secrets"] = {"GITHUB_TOKEN": github_token} + logger.info( + "GitHub token provided but not sent to Cloud API. " + "OpenHands Cloud uses your connected GitHub account for repository access." + ) data = json.dumps(payload).encode("utf-8") request = urllib.request.Request(url, data=data, method="POST") From 10fcb495f96620d77215a9efed5be534e2dc370c Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 8 Feb 2026 16:57:07 +0000 Subject: [PATCH 06/19] refactor: Use OpenHandsCloudWorkspace for cloud mode Rewrote cloud mode to use OpenHandsCloudWorkspace instead of directly calling the /api/conversations endpoint. This provides: - Proper sandbox provisioning via OpenHands Cloud API - LLM configuration sent to the cloud sandbox - GITHUB_TOKEN passed to sandbox for posting review comments - Same agent capabilities as SDK mode, but running in the cloud Changes: - agent_script.py: Rewrite run_cloud_mode() to use OpenHandsCloudWorkspace - agent_script.py: Remove _start_cloud_conversation() and CLOUD_REVIEW_COMMENT_TEMPLATE - agent_script.py: Cloud mode now requires LLM_API_KEY (sent to sandbox) - action.yml: Update to require LLM_API_KEY for both modes - action.yml: Add openhands-workspace to dependencies - README.md: Update documentation for new cloud mode architecture - tests: Update tests for new requirements See: https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 37 +-- .../02_pr_review/README.md | 24 +- .../02_pr_review/agent_script.py | 218 ++++++++---------- .../github_workflows/test_pr_review_agent.py | 162 +++---------- 4 files changed, 164 insertions(+), 277 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 585a3722d9..17d3694f16 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -9,15 +9,15 @@ branding: inputs: mode: - description: "Review mode: 'sdk' (run locally in GitHub Actions) or 'cloud' (launch in OpenHands Cloud and exit)" + description: "Review mode: 'sdk' (run locally) or 'cloud' (run in OpenHands Cloud sandbox)" required: false default: sdk llm-model: - description: LLM model to use for the review (only used in 'sdk' mode, ignored in 'cloud' mode) + description: LLM model to use for the review (used in both modes) required: false default: anthropic/claude-sonnet-4-5-20250929 llm-base-url: - description: LLM base URL (optional, for custom LLM endpoints, only used in 'sdk' mode) + description: LLM base URL (optional, for custom LLM endpoints) required: false default: '' review-style: @@ -34,11 +34,11 @@ inputs: required: false default: main llm-api-key: - description: LLM API key (required for 'sdk' mode, not required for 'cloud' mode) + description: LLM API key (required for both modes) required: false default: '' github-token: - description: GitHub token for API access (required for both modes - used to post PR comments) + description: GitHub token for API access (required for both modes - passed to cloud sandbox in cloud mode) required: false default: '' openhands-cloud-api-key: @@ -88,7 +88,7 @@ runs: - name: Install OpenHands dependencies shell: bash run: | - uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools + uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools ./software-agent-sdk/openhands-workspace - name: Check required configuration shell: bash @@ -100,17 +100,20 @@ runs: run: | echo "Mode: $MODE" - # GITHUB_TOKEN is required for both modes (used to post PR comments) + # GITHUB_TOKEN is required for both modes if [ -z "$GITHUB_TOKEN" ]; then echo "Error: github-token is required for both modes." exit 1 fi + # LLM_API_KEY is required for both modes + if [ -z "$LLM_API_KEY" ]; then + echo "Error: llm-api-key is required for both modes." + exit 1 + fi + if [ "$MODE" = "sdk" ]; then - if [ -z "$LLM_API_KEY" ]; then - echo "Error: llm-api-key is required for 'sdk' mode." - exit 1 - fi + : # SDK mode only needs the base requirements elif [ "$MODE" = "cloud" ]; then if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then echo "Error: openhands-cloud-api-key is required for 'cloud' mode." @@ -125,14 +128,12 @@ runs: echo "PR Title: ${{ github.event.pull_request.title }}" echo "Repository: ${{ github.repository }}" echo "SDK Version: ${{ inputs.sdk-version }}" - 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 "LLM model: ${{ inputs.llm-model }}" + if [ -n "${{ inputs.llm-base-url }}" ]; then + echo "LLM base URL: ${{ inputs.llm-base-url }}" + fi + if [ "$MODE" = "cloud" ]; then echo "OpenHands Cloud API URL: ${{ inputs.openhands-cloud-api-url }}" - echo "Note: LLM model is configured in your OpenHands Cloud account" fi - name: Run PR review diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index 66c40a7b08..9fcec0fc36 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -57,10 +57,11 @@ Set the following secrets in your GitHub repository settings based on your chose - Get one from the [OpenHands LLM Provider](https://docs.all-hands.dev/openhands/usage/llms/openhands-llms) **For Cloud Mode:** +- **`LLM_API_KEY`** (required): Your LLM API key (sent to the cloud sandbox) - **`OPENHANDS_CLOUD_API_KEY`** (required): Your OpenHands Cloud API key - - Get one from your [OpenHands Cloud account](https://app.all-hands.dev) + - Get one from your [OpenHands Cloud account settings](https://app.all-hands.dev/settings/api-keys) -**Note**: The workflow automatically uses the `GITHUB_TOKEN` secret that's available in all GitHub Actions workflows. For cloud mode, you may not need a GitHub token if your OpenHands Cloud account already has access to the repository. +**Note**: The workflow automatically uses the `GITHUB_TOKEN` secret that's available in all GitHub Actions workflows. In cloud mode, this token is passed to the cloud sandbox so the agent can post review comments. ### 3. Customize the workflow (optional) @@ -94,25 +95,32 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. - name: Run PR Review uses: ./.github/actions/pr-review with: - # Review mode: 'cloud' launches the review in OpenHands Cloud + # Review mode: 'cloud' runs in OpenHands Cloud sandbox mode: cloud + # LLM configuration (sent to cloud sandbox) + llm-model: anthropic/claude-sonnet-4-5-20250929 # Review style: roasted (other option: standard) review-style: roasted # SDK git ref to use sdk-version: main # Secrets for cloud mode + llm-api-key: ${{ secrets.LLM_API_KEY }} openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} # Optional: custom cloud API URL # openhands-cloud-api-url: https://app.all-hands.dev - # Optional: GitHub token for posting comments (may not be needed if cloud has repo access) + # GitHub token - passed to cloud sandbox for posting review comments github-token: ${{ secrets.GITHUB_TOKEN }} ``` **Cloud Mode Benefits:** -- Faster CI completion (exits after starting the review) -- Track review progress in OpenHands Cloud UI -- Interact with the review conversation -- Uses the LLM model configured in your OpenHands Cloud account +- Runs in a fully managed cloud sandbox environment +- No local Docker or infrastructure needed +- Same capabilities as SDK mode but in the cloud + +**Cloud Mode Architecture:** +- Uses [OpenHandsCloudWorkspace](https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace) to provision a sandbox +- LLM configuration and GITHUB_TOKEN are sent to the cloud sandbox +- The agent runs in the cloud and posts review comments directly ### 4. Create the review label 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 136d35fa35..e0a08e9933 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -19,10 +19,10 @@ Environment Variables: MODE: Review mode ('sdk' or 'cloud', default: 'sdk') - 'sdk': Run the agent locally using the SDK (default) - - 'cloud': Launch a review task in OpenHands Cloud and exit - LLM_API_KEY: API key for the LLM (required for 'sdk' mode, ignored in 'cloud' mode) - LLM_MODEL: Language model to use (required for 'sdk' mode, ignored in 'cloud' mode) - LLM_BASE_URL: Optional base URL for LLM API (only used in 'sdk' mode) + - 'cloud': Run the agent in OpenHands Cloud sandbox + LLM_API_KEY: API key for the LLM (required for both modes) + 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) PR_NUMBER: Pull request number (required) PR_TITLE: Pull request title (required) @@ -34,8 +34,11 @@ 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) -Note: In 'cloud' mode, LLM_MODEL and LLM_BASE_URL are ignored. The reviewer bot -will use the model configured in your OpenHands Cloud account. +Note on 'cloud' mode: +- Uses OpenHandsCloudWorkspace to provision a cloud sandbox +- The LLM configuration is sent to the cloud sandbox +- GITHUB_TOKEN is passed to the cloud sandbox for posting review comments +- See https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. @@ -48,10 +51,13 @@ import urllib.request from pathlib import Path +from pydantic import SecretStr + from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger from openhands.sdk.conversation import get_agent_final_response from openhands.sdk.git.utils import run_git_command from openhands.tools.preset.default import get_default_condenser, get_default_tools +from openhands.workspace import OpenHandsCloudWorkspace # Add the script directory to Python path so we can import prompt.py @@ -66,17 +72,6 @@ # Maximum total diff size MAX_TOTAL_DIFF = 100000 -# Template for cloud mode PR comment -CLOUD_REVIEW_COMMENT_TEMPLATE = """\ -🤖 **OpenHands PR Review Started** - -A code review has been initiated in OpenHands Cloud. - -📍 **Track progress here:** [{conversation_url}]({conversation_url}) - -The review will analyze the changes and post inline comments \ -directly on this PR when complete.""" - def _get_required_env(name: str) -> str: value = os.getenv(name) @@ -228,119 +223,104 @@ def _prepare_review_context(pr_info: dict, review_style: str) -> tuple[str, str] return prompt, skill_trigger -def _start_cloud_conversation( - cloud_api_url: str, - cloud_api_key: str, - prompt: str, - github_token: str | None = None, -) -> str: - """Start a conversation in OpenHands Cloud. - - Args: - cloud_api_url: OpenHands Cloud API URL - cloud_api_key: OpenHands Cloud API key - prompt: The initial prompt for the conversation - github_token: Optional GitHub token to pass as a secret - - Returns: - The conversation ID - """ - url = f"{cloud_api_url}/api/conversations" - - # Build the request payload - # Note: The OpenHands Cloud API does not accept secrets directly. - # Instead, it uses the user's connected GitHub account for repository access. - # The github_token is available in the agent's environment for API calls. - payload: dict = { - "initial_user_msg": prompt, - } - - if github_token: - logger.info( - "GitHub token provided but not sent to Cloud API. " - "OpenHands Cloud uses your connected GitHub account for repository access." - ) - - data = json.dumps(payload).encode("utf-8") - request = urllib.request.Request(url, data=data, method="POST") - request.add_header("Authorization", f"Bearer {cloud_api_key}") - request.add_header("Content-Type", "application/json") - - try: - with urllib.request.urlopen(request, timeout=60) as response: - result = json.loads(response.read().decode("utf-8")) - conversation_id = result.get("conversation_id") or result.get("id") - if not conversation_id: - raise RuntimeError( - f"Cloud API response missing conversation_id: {result}" - ) - logger.info(f"Created cloud conversation: {conversation_id}") - return conversation_id - except urllib.error.HTTPError as e: - details = (e.read() or b"").decode("utf-8", errors="replace").strip() - raise RuntimeError( - f"Cloud API request failed: HTTP {e.code} {e.reason}. {details}" - ) from e - except urllib.error.URLError as e: - raise RuntimeError(f"Cloud API request failed: {e.reason}") from e - - -def run_cloud_mode(pr_info: dict, prompt: str) -> None: +def run_cloud_mode(prompt: str, skill_trigger: str) -> None: """Run the PR review in OpenHands Cloud mode. - This mode: - 1. Creates an OpenHands Cloud conversation - 2. Sends the review prompt to the cloud - 3. Posts a comment on the PR with the cloud conversation URL - 4. Exits without monitoring the conversation + This mode uses OpenHandsCloudWorkspace to provision a sandbox in OpenHands Cloud + and runs the agent there. The LLM configuration and GITHUB_TOKEN are sent to + the cloud sandbox. - Note: In cloud mode, LLM_MODEL and LLM_BASE_URL are ignored. The reviewer - bot will use the model configured in your OpenHands Cloud account. + See: https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace Args: - pr_info: Dictionary containing PR information (repo_name, number) - prompt: The review prompt to send to the cloud + prompt: The review prompt to send to the agent + skill_trigger: The skill trigger to use (e.g., '/codereview') """ # Get cloud-specific configuration (already validated in main()) cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") + # Get LLM configuration (required for cloud mode too) + api_key = _get_required_env("LLM_API_KEY") + model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + base_url = os.getenv("LLM_BASE_URL") + github_token = os.getenv("GITHUB_TOKEN") + logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") - logger.info( - "Note: LLM_MODEL and LLM_BASE_URL are ignored in cloud mode. " - "The model configured in your OpenHands Cloud account will be used." - ) + logger.info(f"LLM Model: {model}") try: - logger.info("Starting OpenHands Cloud conversation...") + # Configure LLM - note: base_url should be None for cloud sandbox + # as it needs direct access to the LLM provider + llm = LLM( + usage_id="pr_review_agent", + model=model, + base_url=base_url or None, + api_key=SecretStr(api_key), + drop_params=True, + ) - # Create conversation via cloud API - github_token = os.getenv("GITHUB_TOKEN") - conversation_id = _start_cloud_conversation( + # Create the cloud workspace + with OpenHandsCloudWorkspace( cloud_api_url=cloud_api_url, cloud_api_key=cloud_api_key, - prompt=prompt, - github_token=github_token, - ) - - # Build the cloud conversation URL - conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" - logger.info(f"Cloud conversation URL: {conversation_url}") - logger.info("Review task started in OpenHands Cloud") - - # Post a comment on the PR with the cloud URL - comment_body = CLOUD_REVIEW_COMMENT_TEMPLATE.format( - conversation_url=conversation_url - ) - - post_github_comment( - repo_name=pr_info["repo_name"], - pr_number=pr_info["number"], - body=comment_body, - ) - - logger.info("Posted cloud review notification comment to PR") - logger.info("Exiting - review will continue in OpenHands Cloud") + ) as workspace: + logger.info("Cloud sandbox provisioned successfully") + + # Create AgentContext with public skills enabled + agent_context = AgentContext(load_public_skills=True) + + # Create agent with default tools + agent = 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"}) + ), + ) + + # Create conversation with secrets for masking + secrets = {"LLM_API_KEY": api_key} + if github_token: + secrets["GITHUB_TOKEN"] = github_token + + conversation = Conversation( + agent=agent, + workspace=workspace, + secrets=secrets, + ) + + logger.info("Starting PR review analysis in cloud sandbox...") + 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 + conversation.send_message(prompt) + conversation.run() + + # 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 (Cloud Mode) ===") + 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}") + + logger.info("PR review completed successfully (cloud mode)") except Exception as e: logger.error(f"Cloud mode PR review failed: {e}") @@ -461,14 +441,14 @@ def main(): "PR_BASE_BRANCH", "PR_HEAD_BRANCH", "REPO_NAME", + "LLM_API_KEY", # Required for both modes ] if mode == "sdk": - # SDK mode requires LLM_API_KEY for local LLM access - required_vars = base_required_vars + ["LLM_API_KEY"] + # SDK mode only requires base vars (including LLM_API_KEY) + required_vars = base_required_vars else: # cloud mode - # Cloud mode only requires OPENHANDS_CLOUD_API_KEY - # LLM_MODEL and LLM_BASE_URL are ignored - cloud uses its own model + # Cloud mode also requires OPENHANDS_CLOUD_API_KEY for workspace provisioning required_vars = base_required_vars + ["OPENHANDS_CLOUD_API_KEY"] missing_vars = [var for var in required_vars if not os.getenv(var)] @@ -500,7 +480,7 @@ def main(): # Run the appropriate mode if mode == "cloud": - run_cloud_mode(pr_info, prompt) + run_cloud_mode(prompt, skill_trigger) else: run_sdk_mode(prompt, skill_trigger) diff --git a/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py index 0c07956a7f..6462fe16c7 100644 --- a/tests/github_workflows/test_pr_review_agent.py +++ b/tests/github_workflows/test_pr_review_agent.py @@ -1,6 +1,5 @@ """Tests for PR review agent script.""" -import json import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -56,80 +55,6 @@ def test_post_github_comment_missing_token(): post_github_comment("owner/repo", "123", "Test comment") -def test_start_cloud_conversation_success(): - """Test successful cloud conversation creation.""" - from agent_script import ( # type: ignore[import-not-found] - _start_cloud_conversation, - ) - - mock_response = MagicMock() - mock_response.read.return_value = json.dumps( - {"conversation_id": "test-conv-123"} - ).encode("utf-8") - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) - - with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen: - result = _start_cloud_conversation( - cloud_api_url="https://app.all-hands.dev", - cloud_api_key="test-cloud-key", - prompt="Test prompt", - github_token="test-github-token", - ) - - assert result == "test-conv-123" - mock_urlopen.assert_called_once() - call_args = mock_urlopen.call_args - request = call_args[0][0] - - assert request.full_url == "https://app.all-hands.dev/api/conversations" - assert request.get_header("Authorization") == "Bearer test-cloud-key" - assert request.get_header("Content-type") == "application/json" - - -def test_start_cloud_conversation_with_id_field(): - """Test cloud conversation handles 'id' field in response.""" - from agent_script import ( # type: ignore[import-not-found] - _start_cloud_conversation, - ) - - mock_response = MagicMock() - mock_response.read.return_value = json.dumps({"id": "conv-456"}).encode("utf-8") - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) - - with patch("urllib.request.urlopen", return_value=mock_response): - result = _start_cloud_conversation( - cloud_api_url="https://app.all-hands.dev", - cloud_api_key="test-key", - prompt="Test", - ) - - assert result == "conv-456" - - -def test_start_cloud_conversation_missing_id(): - """Test cloud conversation raises error when response missing conversation_id.""" - from agent_script import ( # type: ignore[import-not-found] - _start_cloud_conversation, - ) - - mock_response = MagicMock() - mock_response.read.return_value = json.dumps({"status": "ok"}).encode("utf-8") - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) - - with ( - patch("urllib.request.urlopen", return_value=mock_response), - pytest.raises(RuntimeError, match="missing conversation_id"), - ): - _start_cloud_conversation( - cloud_api_url="https://app.all-hands.dev", - cloud_api_key="test-key", - prompt="Test", - ) - - def test_prepare_review_context(): """Test that _prepare_review_context returns correct prompt and skill trigger.""" from agent_script import ( # type: ignore[import-not-found] @@ -228,10 +153,12 @@ def test_cloud_mode_requires_cloud_api_key(): """Test that cloud mode fails without OPENHANDS_CLOUD_API_KEY.""" from agent_script import main # type: ignore[import-not-found] - # Set up minimal environment for cloud mode but missing OPENHANDS_CLOUD_API_KEY + # Set up environment for cloud mode but missing OPENHANDS_CLOUD_API_KEY + # Note: Cloud mode now also requires LLM_API_KEY env = { "MODE": "cloud", "GITHUB_TOKEN": "test-token", + "LLM_API_KEY": "test-llm-key", "PR_NUMBER": "123", "PR_TITLE": "Test PR", "PR_BASE_BRANCH": "main", @@ -249,6 +176,32 @@ def test_cloud_mode_requires_cloud_api_key(): assert exc_info.value.code == 1 +def test_cloud_mode_requires_llm_api_key(): + """Test that cloud mode also fails without LLM_API_KEY.""" + from agent_script import main # type: ignore[import-not-found] + + # Cloud mode requires both OPENHANDS_CLOUD_API_KEY and LLM_API_KEY + env = { + "MODE": "cloud", + "GITHUB_TOKEN": "test-token", + "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", + # LLM_API_KEY intentionally missing + } + + 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_require_github_token(): """Test that both modes require GITHUB_TOKEN.""" from agent_script import main # type: ignore[import-not-found] @@ -277,6 +230,7 @@ def test_both_modes_require_github_token(): cloud_env = { "MODE": "cloud", "OPENHANDS_CLOUD_API_KEY": "test-key", + "LLM_API_KEY": "test-llm-key", "PR_NUMBER": "123", "PR_TITLE": "Test PR", "PR_BASE_BRANCH": "main", @@ -292,59 +246,3 @@ def test_both_modes_require_github_token(): main() assert exc_info.value.code == 1 - - -def test_conversation_url_format(): - """Test that conversation URL is correctly formatted.""" - cloud_api_url = "https://app.all-hands.dev" - conversation_id = "12345678-1234-1234-1234-123456789abc" - - expected_url = f"{cloud_api_url}/conversations/{conversation_id}" - assert expected_url == ( - "https://app.all-hands.dev/conversations/12345678-1234-1234-1234-123456789abc" - ) - - -def test_conversation_url_with_custom_api_url(): - """Test conversation URL with custom cloud API URL.""" - cloud_api_url = "https://custom.openhands.dev" - conversation_id = "test-conversation-id" - - expected_url = f"{cloud_api_url}/conversations/{conversation_id}" - assert expected_url == ( - "https://custom.openhands.dev/conversations/test-conversation-id" - ) - - -def test_comment_body_contains_url(): - """Test that comment body template contains the conversation URL.""" - from agent_script import ( # type: ignore[import-not-found] - CLOUD_REVIEW_COMMENT_TEMPLATE, - ) - - conversation_url = "https://app.all-hands.dev/conversations/test-id" - comment_body = CLOUD_REVIEW_COMMENT_TEMPLATE.format( - conversation_url=conversation_url - ) - - assert conversation_url in comment_body - assert "OpenHands PR Review Started" in comment_body - assert "Track progress here" in comment_body - - -def test_comment_body_is_markdown(): - """Test that comment body template uses markdown formatting.""" - from agent_script import ( # type: ignore[import-not-found] - CLOUD_REVIEW_COMMENT_TEMPLATE, - ) - - conversation_url = "https://app.all-hands.dev/conversations/test-id" - comment_body = CLOUD_REVIEW_COMMENT_TEMPLATE.format( - conversation_url=conversation_url - ) - - # Check for markdown bold syntax - assert "**OpenHands PR Review Started**" in comment_body - assert "**Track progress here:**" in comment_body - # Check for markdown link syntax - assert f"[{conversation_url}]({conversation_url})" in comment_body From 71f7f5a025a74917a0571504ae063af0eb746d45 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 9 Feb 2026 15:44:58 +0000 Subject: [PATCH 07/19] refactor: Unify SDK and cloud mode with shared agent definition - Revert agent_script.py to main version and add cloud mode support - Share core agent definition logic between SDK and cloud modes - Cloud mode uses OpenHandsCloudWorkspace with keep_alive=True - Cloud mode calls run(blocking=False) to start agent without waiting - Add CLOUD_MODE_INSTRUCTION to instruct agent to post review comment when done - Post PR comment with conversation URL for tracking progress - Update tests to match new implementation The only difference between modes is: - SDK mode: runs locally and waits for completion - Cloud mode: runs in cloud sandbox, exits immediately after starting Co-authored-by: openhands --- .../02_pr_review/agent_script.py | 467 +++++++++--------- .../github_workflows/test_pr_review_agent.py | 47 +- 2 files changed, 233 insertions(+), 281 deletions(-) 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 e0a08e9933..e535278a1e 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -19,8 +19,8 @@ Environment Variables: MODE: Review mode ('sdk' or 'cloud', default: 'sdk') - 'sdk': Run the agent locally using the SDK (default) - - 'cloud': Run the agent in OpenHands Cloud sandbox - LLM_API_KEY: API key for the LLM (required for both modes) + - 'cloud': Run the agent in OpenHands Cloud sandbox (non-blocking) + LLM_API_KEY: API key for the LLM (required) 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) @@ -36,8 +36,9 @@ Note on 'cloud' mode: - Uses OpenHandsCloudWorkspace to provision a cloud sandbox -- The LLM configuration is sent to the cloud sandbox -- GITHUB_TOKEN is passed to the cloud sandbox for posting review comments +- The agent runs asynchronously in the cloud (non-blocking) +- Posts a comment on the PR with a link to track progress +- Agent is instructed to post a review comment on the PR when done - See https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace For setup instructions, usage examples, and GitHub Actions integration, @@ -51,6 +52,7 @@ import urllib.request from pathlib import Path +from lmnr import Laminar from pydantic import SecretStr from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger @@ -183,108 +185,172 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: raise RuntimeError(f"GitHub comment API request failed: {e.reason}") from e -def _prepare_review_context(pr_info: dict, review_style: str) -> tuple[str, str]: - """Prepare the review context including diff and prompt. +# Additional instruction for cloud mode to post review comment when done +CLOUD_MODE_INSTRUCTION = """ - This is shared setup code used by both cloud and SDK modes. +IMPORTANT: When you have completed the code review, you MUST post a summary comment +on the PR using the GitHub API. Use the following curl command: - Args: - pr_info: Dictionary containing PR information - review_style: Review style ('standard' or 'roasted') +```bash +curl -X POST \\ + -H "Authorization: Bearer $GITHUB_TOKEN" \\ + -H "Accept: application/vnd.github.v3+json" \\ + -H "Content-Type: application/json" \\ + "https://api.github.com/repos/{repo_name}/issues/{pr_number}/comments" \\ + -d '{{"body": "## Code Review Complete\\n\\n"}}' +``` - Returns: - Tuple of (prompt, skill_trigger) - """ - pr_diff = get_truncated_pr_diff() - logger.info(f"Got PR diff with {len(pr_diff)} characters") +Replace `` with a brief summary of your review findings. +This is required because the review is running asynchronously in the cloud. +""" - # Get the HEAD commit SHA for inline comments - commit_id = get_head_commit_sha() - logger.info(f"HEAD commit SHA: {commit_id}") - # Determine skill trigger based on review style - skill_trigger = ( - "/codereview" if review_style == "standard" else "/codereview-roasted" - ) +def main(): + """Run the PR review agent.""" + logger.info("Starting PR review process...") - # Create the review prompt using the template - prompt = PROMPT.format( - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - repo_name=pr_info.get("repo_name", "N/A"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - pr_number=pr_info.get("number", "N/A"), - commit_id=commit_id, - skill_trigger=skill_trigger, - diff=pr_diff, - ) + # Get mode + mode = os.getenv("MODE", "sdk").lower() + if mode not in ("sdk", "cloud"): + logger.warning(f"Unknown MODE '{mode}', using 'sdk'") + mode = "sdk" - return prompt, skill_trigger + logger.info(f"Mode: {mode}") + # Validate required environment variables based on mode + required_vars = [ + "LLM_API_KEY", + "GITHUB_TOKEN", + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] -def run_cloud_mode(prompt: str, skill_trigger: str) -> None: - """Run the PR review in OpenHands Cloud mode. + if mode == "cloud": + required_vars.append("OPENHANDS_CLOUD_API_KEY") - This mode uses OpenHandsCloudWorkspace to provision a sandbox in OpenHands Cloud - and runs the agent there. The LLM configuration and GITHUB_TOKEN are sent to - the cloud sandbox. + 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) - See: https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace + # These are guaranteed to be set after validation above + github_token = _get_required_env("GITHUB_TOKEN") + api_key = _get_required_env("LLM_API_KEY") - Args: - prompt: The review prompt to send to the agent - skill_trigger: The skill trigger to use (e.g., '/codereview') - """ - # Get cloud-specific configuration (already validated in main()) - cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") - cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") + # 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"), + } - # Get LLM configuration (required for cloud mode too) - api_key = _get_required_env("LLM_API_KEY") - model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") - base_url = os.getenv("LLM_BASE_URL") - github_token = os.getenv("GITHUB_TOKEN") + # 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"Using OpenHands Cloud API: {cloud_api_url}") - logger.info(f"LLM Model: {model}") + logger.info(f"Reviewing PR #{pr_info['number']}: {pr_info['title']}") + logger.info(f"Review style: {review_style}") try: - # Configure LLM - note: base_url should be None for cloud sandbox - # as it needs direct access to the LLM provider - llm = LLM( - usage_id="pr_review_agent", - model=model, - base_url=base_url or None, - api_key=SecretStr(api_key), - drop_params=True, + 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}") + + # Create the review prompt using the template + skill_trigger = ( + "/codereview" if review_style == "standard" else "/codereview-roasted" + ) + prompt = PROMPT.format( + title=pr_info.get("title", "N/A"), + body=pr_info.get("body", "No description provided"), + repo_name=pr_info.get("repo_name", "N/A"), + base_branch=pr_info.get("base_branch", "main"), + head_branch=pr_info.get("head_branch", "N/A"), + pr_number=pr_info.get("number", "N/A"), + commit_id=commit_id, + skill_trigger=skill_trigger, + diff=pr_diff, + ) + + # For cloud mode, add instruction to post review comment when done + if mode == "cloud": + prompt += CLOUD_MODE_INSTRUCTION.format( + repo_name=pr_info["repo_name"], + pr_number=pr_info["number"], + ) + + # Configure LLM - shared between both modes + model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + base_url = os.getenv("LLM_BASE_URL") + + # For cloud mode, use SecretStr for API key and don't use local proxy + if mode == "cloud": + llm = LLM( + usage_id="pr_review_agent", + model=model, + base_url=base_url or None, # None for direct provider access + api_key=SecretStr(api_key), + drop_params=True, + ) + else: + 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) + + # Create AgentContext with public skills enabled - shared between both modes + agent_context = AgentContext(load_public_skills=True) + + # Create agent with default tools - shared between both modes + agent = 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"}) + ), ) - # Create the cloud workspace - with OpenHandsCloudWorkspace( - cloud_api_url=cloud_api_url, - cloud_api_key=cloud_api_key, - ) as workspace: - logger.info("Cloud sandbox provisioned successfully") - - # Create AgentContext with public skills enabled - agent_context = AgentContext(load_public_skills=True) - - # Create agent with default tools - agent = 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"}) - ), + # Create secrets for masking - shared between both modes + secrets = {} + if api_key: + secrets["LLM_API_KEY"] = api_key + if github_token: + secrets["GITHUB_TOKEN"] = github_token + + # Create workspace and conversation based on mode + if mode == "cloud": + cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") + cloud_api_url = os.getenv( + "OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev" ) - # Create conversation with secrets for masking - secrets = {"LLM_API_KEY": api_key} - if github_token: - secrets["GITHUB_TOKEN"] = github_token + logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") + + # Use OpenHandsCloudWorkspace with keep_alive=True so sandbox continues + # running after we exit + workspace = OpenHandsCloudWorkspace( + cloud_api_url=cloud_api_url, + cloud_api_key=cloud_api_key, + keep_alive=True, + ) conversation = Conversation( agent=agent, @@ -294,11 +360,45 @@ def run_cloud_mode(prompt: str, skill_trigger: str) -> None: logger.info("Starting PR review analysis in cloud sandbox...") logger.info(f"Using skill trigger: {skill_trigger}") + + # Send the prompt and start the agent (non-blocking) + conversation.send_message(prompt) + conversation.run(blocking=False) + + # Get the conversation URL for tracking + conversation_id = str(conversation.state.id) + conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" + + # Post a comment on the PR with the conversation 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 a review comment when the analysis is complete." + ) + post_github_comment(pr_info["repo_name"], pr_info["number"], comment_body) + + logger.info(f"Cloud review started: {conversation_url}") + logger.info("Workflow complete - review continues in cloud") + + else: + # SDK mode - run locally and wait for completion + cwd = os.getcwd() + + 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 + # Send the prompt and run the agent (blocking) conversation.send_message(prompt) conversation.run() @@ -309,7 +409,7 @@ def run_cloud_mode(prompt: str, skill_trigger: str) -> None: # Print cost information for CI output metrics = conversation.conversation_stats.get_combined_metrics() - print("\n=== PR Review Cost Summary (Cloud Mode) ===") + 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 @@ -320,170 +420,43 @@ def run_cloud_mode(prompt: str, skill_trigger: str) -> None: if token_usage.cache_write_tokens > 0: print(f"Cache Write Tokens: {token_usage.cache_write_tokens}") - logger.info("PR review completed successfully (cloud mode)") - - except Exception as e: - logger.error(f"Cloud mode PR review failed: {e}") - sys.exit(1) - - -def run_sdk_mode(prompt: str, skill_trigger: str) -> None: - """Run the PR review in SDK mode (local execution). - - This is the original behavior - runs the agent locally and monitors - until completion. - - Args: - prompt: The review prompt to send to the agent - skill_trigger: The skill trigger to use (e.g., '/codereview') - """ - github_token = os.getenv("GITHUB_TOKEN") - - try: - # 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() - - # Create AgentContext with public skills enabled - agent_context = AgentContext(load_public_skills=True) - - # Create agent with default tools and agent context - agent = 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"}) - ), - ) - - # Create conversation with secrets for masking - 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 - conversation.send_message(prompt) - conversation.run() - - # 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}") - - logger.info("PR review completed successfully") + # Capture and store trace ID for delayed evaluation + trace_id = Laminar.get_trace_id() + if trace_id: + Laminar.set_trace_metadata( + { + "pr_number": pr_info["number"], + "repo_name": pr_info["repo_name"], + "workflow_phase": "review", + "review_style": review_style, + } + ) + + trace_data = { + "trace_id": str(trace_id), + "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}") + print("\n=== Laminar Trace ===") + print(f"Trace ID: {trace_id}") + + Laminar.flush() + else: + logger.warning( + "No Laminar trace ID found - observability may not be enabled" + ) + + logger.info("PR review completed successfully") except Exception as e: logger.error(f"PR review failed: {e}") sys.exit(1) -def main(): - """Run the PR review agent.""" - logger.info("Starting PR review process...") - - # Get mode - 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}") - - # Validate required environment variables based on mode - base_required_vars = [ - "GITHUB_TOKEN", - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - "LLM_API_KEY", # Required for both modes - ] - - if mode == "sdk": - # SDK mode only requires base vars (including LLM_API_KEY) - required_vars = base_required_vars - else: # cloud mode - # Cloud mode also requires OPENHANDS_CLOUD_API_KEY for workspace provisioning - required_vars = base_required_vars + ["OPENHANDS_CLOUD_API_KEY"] - - 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) - - # 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"), - } - - # 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}") - - # Prepare review context (shared by both modes) - prompt, skill_trigger = _prepare_review_context(pr_info, review_style) - - # Run the appropriate mode - if mode == "cloud": - run_cloud_mode(prompt, skill_trigger) - else: - run_sdk_mode(prompt, skill_trigger) - - if __name__ == "__main__": main() diff --git a/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py index 6462fe16c7..b2e309ad8d 100644 --- a/tests/github_workflows/test_pr_review_agent.py +++ b/tests/github_workflows/test_pr_review_agent.py @@ -55,44 +55,23 @@ def test_post_github_comment_missing_token(): post_github_comment("owner/repo", "123", "Test comment") -def test_prepare_review_context(): - """Test that _prepare_review_context returns correct prompt and skill trigger.""" +def test_cloud_mode_instruction_format(): + """Test that CLOUD_MODE_INSTRUCTION can be formatted correctly.""" from agent_script import ( # type: ignore[import-not-found] - _prepare_review_context, + CLOUD_MODE_INSTRUCTION, ) - pr_info = { - "number": "123", - "title": "Test PR", - "body": "Test body", - "repo_name": "owner/repo", - "base_branch": "main", - "head_branch": "feature", - } + # Test that the instruction can be formatted without errors + formatted = CLOUD_MODE_INSTRUCTION.format( + repo_name="owner/repo", + pr_number="123", + ) - # Mock the functions that _prepare_review_context calls - with ( - patch.dict( - "os.environ", - { - "GITHUB_TOKEN": "test-token", - "REPO_NAME": "owner/repo", - "PR_NUMBER": "123", - }, - clear=False, - ), - patch("agent_script.get_truncated_pr_diff", return_value="mock diff content"), - patch("agent_script.get_head_commit_sha", return_value="abc123"), - ): - # Test standard review style - prompt, skill_trigger = _prepare_review_context(pr_info, "standard") - assert skill_trigger == "/codereview" - assert "Test PR" in prompt - assert "mock diff content" in prompt - - # Test roasted review style - prompt, skill_trigger = _prepare_review_context(pr_info, "roasted") - assert skill_trigger == "/codereview-roasted" + # Verify the formatted instruction contains the expected values + assert "owner/repo" in formatted + assert "123" in formatted + assert "GITHUB_TOKEN" in formatted + assert "curl" in formatted def test_mode_defaults_to_sdk(): From c5648688008d61d9a2d40a5ee90fe94015783246 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 08:49:37 +0000 Subject: [PATCH 08/19] fix: Use Cloud API directly for cloud mode PR review Instead of using OpenHandsCloudWorkspace to create a sandbox and then creating a conversation on the agent-server inside it, this change uses the OpenHands Cloud /api/conversations endpoint directly. Benefits: - No LLM credentials needed - cloud uses user's configured LLM - Proper conversation URL that works in the OpenHands Cloud UI - Simpler implementation - just one API call Changes: - Add _start_cloud_conversation() to call Cloud API directly - Update validation to only require LLM_API_KEY for sdk mode - Include GITHUB_TOKEN in the prompt for cloud mode (API doesn't support passing secrets separately) - Update action.yml descriptions to reflect mode differences Tested: Successfully created conversation 578fcb531dd34ef3b87fe7fd55b29e46 Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 42 ++-- .../02_pr_review/agent_script.py | 224 +++++++++++------- 2 files changed, 156 insertions(+), 110 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 1d03975104..5d8f7b4126 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -9,20 +9,19 @@ branding: inputs: mode: - description: "Review mode: 'sdk' (run locally) or 'cloud' (run in OpenHands Cloud sandbox)" + 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 (used in both modes) + 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: @@ -30,15 +29,15 @@ 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 for both modes) + description: LLM API key (required for 'sdk' mode only, cloud mode uses user's configured LLM) required: false default: '' github-token: - description: GitHub token for API access (required for both modes - passed to cloud sandbox in cloud mode) + description: GitHub token for API access (required for both modes) 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 @@ -49,7 +48,7 @@ inputs: 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: '' @@ -109,15 +108,14 @@ runs: exit 1 fi - # LLM_API_KEY is required for both modes - if [ -z "$LLM_API_KEY" ]; then - echo "Error: llm-api-key is required for both modes." - exit 1 - fi - if [ "$MODE" = "sdk" ]; then - : # SDK mode only needs the base requirements + # SDK mode requires LLM_API_KEY + if [ -z "$LLM_API_KEY" ]; then + echo "Error: llm-api-key is required for 'sdk' mode." + exit 1 + fi elif [ "$MODE" = "cloud" ]; then + # Cloud mode requires OPENHANDS_CLOUD_API_KEY (LLM is configured in cloud) if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then echo "Error: openhands-cloud-api-key is required for 'cloud' mode." exit 1 @@ -131,12 +129,14 @@ 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 }}" - fi - if [ "$MODE" = "cloud" ]; then + 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 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 b61d0b512d..234648c1c4 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -19,8 +19,8 @@ Environment Variables: MODE: Review mode ('sdk' or 'cloud', default: 'sdk') - 'sdk': Run the agent locally using the SDK (default) - - 'cloud': Run the agent in OpenHands Cloud sandbox (non-blocking) - LLM_API_KEY: API key for the LLM (required) + - 'cloud': Run the agent in OpenHands Cloud (non-blocking) + LLM_API_KEY: API key for the LLM (required for 'sdk' mode only) 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) @@ -35,11 +35,11 @@ OPENHANDS_CLOUD_API_URL: OpenHands Cloud API URL (default: https://app.all-hands.dev) Note on 'cloud' mode: -- Uses OpenHandsCloudWorkspace to provision a cloud sandbox +- Creates a conversation directly via OpenHands Cloud API +- No LLM credentials needed - uses the user's cloud-configured LLM - The agent runs asynchronously in the cloud (non-blocking) -- Posts a comment on the PR with a link to track progress +- Posts a comment on the PR with a link to track progress in the UI - Agent is instructed to post a review comment on the PR when done -- See https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. @@ -53,13 +53,11 @@ from pathlib import Path from lmnr import Laminar -from pydantic import SecretStr from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger from openhands.sdk.conversation import get_agent_final_response from openhands.sdk.git.utils import run_git_command from openhands.tools.preset.default import get_default_condenser, get_default_tools -from openhands.workspace import OpenHandsCloudWorkspace # Add the script directory to Python path so we can import prompt.py @@ -186,8 +184,19 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: # Additional instruction for cloud mode to post review comment when done +# Note: The GITHUB_TOKEN is provided directly in the instruction since cloud API +# doesn't support passing secrets separately CLOUD_MODE_INSTRUCTION = """ +IMPORTANT CREDENTIALS: +The following GitHub token has been provided for API access: +GITHUB_TOKEN={github_token} + +You can use this token with curl or the GitHub API directly. For example: +```bash +export GITHUB_TOKEN="{github_token}" +``` + IMPORTANT: When you have completed the code review, you MUST post a summary comment on the PR using the GitHub API. Use the following curl command: @@ -205,6 +214,57 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: """ +def _start_cloud_conversation( + cloud_api_url: str, + cloud_api_key: str, + initial_message: str, +) -> tuple[str, str]: + """Start a conversation via OpenHands Cloud API. + + This creates a conversation directly through the Cloud API, which: + - Uses the user's cloud-configured LLM (no LLM credentials needed) + - Provisions a sandbox automatically + - Returns a conversation URL that works in the OpenHands Cloud UI + + Args: + cloud_api_url: OpenHands Cloud API URL (e.g., https://app.all-hands.dev) + cloud_api_key: API key for OpenHands Cloud + initial_message: The initial prompt to send to the agent + + Returns: + Tuple of (conversation_id, conversation_url) + + Raises: + RuntimeError: If the API request fails + """ + url = f"{cloud_api_url}/api/conversations" + + payload = {"initial_user_msg": initial_message} + + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=data, method="POST") + request.add_header("Authorization", f"Bearer {cloud_api_key}") + request.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(request, timeout=120) as response: + result = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as e: + details = (e.read() or b"").decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"OpenHands Cloud API request failed: HTTP {e.code} {e.reason}. {details}" + ) from e + except urllib.error.URLError as e: + raise RuntimeError(f"OpenHands Cloud API request failed: {e.reason}") from e + + conversation_id = result.get("conversation_id") + if not conversation_id: + raise RuntimeError(f"No conversation_id in response: {result}") + + conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" + return conversation_id, conversation_url + + def main(): """Run the PR review agent.""" logger.info("Starting PR review process...") @@ -218,27 +278,36 @@ def main(): logger.info(f"Mode: {mode}") # Validate required environment variables based on mode - required_vars = [ - "LLM_API_KEY", - "GITHUB_TOKEN", - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] - + # Cloud mode doesn't need LLM_API_KEY - uses user's cloud-configured LLM if mode == "cloud": - required_vars.append("OPENHANDS_CLOUD_API_KEY") + required_vars = [ + "OPENHANDS_CLOUD_API_KEY", + "GITHUB_TOKEN", + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] + else: + required_vars = [ + "LLM_API_KEY", + "GITHUB_TOKEN", + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] 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) - # These are guaranteed to be set after validation above + # Get credentials based on mode github_token = _get_required_env("GITHUB_TOKEN") - api_key = _get_required_env("LLM_API_KEY") + api_key = os.getenv("LLM_API_KEY") # May be None in cloud mode # Get PR information pr_info = { @@ -283,91 +352,32 @@ def main(): diff=pr_diff, ) - # For cloud mode, add instruction to post review comment when done + # Handle cloud mode - uses OpenHands Cloud API directly if mode == "cloud": + # Add instruction with GitHub token for API access prompt += CLOUD_MODE_INSTRUCTION.format( repo_name=pr_info["repo_name"], pr_number=pr_info["number"], + github_token=github_token, ) - # Configure LLM - shared between both modes - model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") - base_url = os.getenv("LLM_BASE_URL") - - # For cloud mode, use SecretStr for API key and don't use local proxy - if mode == "cloud": - llm = LLM( - usage_id="pr_review_agent", - model=model, - base_url=base_url or None, # None for direct provider access - api_key=SecretStr(api_key), - drop_params=True, - ) - else: - 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) - - # Create AgentContext with public skills enabled - shared between both modes - agent_context = AgentContext(load_public_skills=True) - - # Create agent with default tools - shared between both modes - agent = 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"}) - ), - ) - - # Create secrets for masking - shared between both modes - secrets = {} - if api_key: - secrets["LLM_API_KEY"] = api_key - if github_token: - secrets["GITHUB_TOKEN"] = github_token - - # Create workspace and conversation based on mode - if mode == "cloud": cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") cloud_api_url = os.getenv( "OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev" ) logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") + logger.info(f"Using skill trigger: {skill_trigger}") - # Use OpenHandsCloudWorkspace with keep_alive=True so sandbox continues - # running after we exit - workspace = OpenHandsCloudWorkspace( + # Create conversation via Cloud API + # This uses the user's cloud-configured LLM - no LLM credentials needed + conversation_id, conversation_url = _start_cloud_conversation( cloud_api_url=cloud_api_url, cloud_api_key=cloud_api_key, - keep_alive=True, + initial_message=prompt, ) - conversation = Conversation( - agent=agent, - workspace=workspace, - secrets=secrets, - ) - - logger.info("Starting PR review analysis in cloud sandbox...") - logger.info(f"Using skill trigger: {skill_trigger}") - - # Send the prompt and start the agent (non-blocking) - conversation.send_message(prompt) - conversation.run(blocking=False) - - # Get the conversation URL for tracking - conversation_id = str(conversation.state.id) - conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" + logger.info(f"Cloud conversation started: {conversation_id}") # Post a comment on the PR with the conversation URL comment_body = ( @@ -378,11 +388,47 @@ def main(): ) post_github_comment(pr_info["repo_name"], pr_info["number"], comment_body) - logger.info(f"Cloud review started: {conversation_url}") + logger.info(f"Cloud review URL: {conversation_url}") logger.info("Workflow complete - review continues in cloud") else: # SDK mode - run locally and wait for completion + + # Configure LLM for SDK mode + 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) + + # Create AgentContext with public skills enabled + agent_context = AgentContext(load_public_skills=True) + + # Create agent with default tools + agent = 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"}) + ), + ) + + # Create secrets for masking + secrets = {} + if api_key: + secrets["LLM_API_KEY"] = api_key + if github_token: + secrets["GITHUB_TOKEN"] = github_token + cwd = os.getcwd() conversation = Conversation( From 52d3984c1e11db08cde131119e4d79192694ccf2 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 09:26:15 +0000 Subject: [PATCH 09/19] docs: Clarify that GITHUB_TOKEN is still needed for workflow, but agent has it auto-available - GITHUB_TOKEN is required for both modes (for fetching PR diff and posting comments) - The key difference is that the agent running in cloud has GITHUB_TOKEN automatically available, so no special handling needed to pass it - Update README and action.yml to clarify this distinction - Simplify CLOUD_MODE_INSTRUCTION since GITHUB_TOKEN is auto-available for the agent Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 4 +- .../02_pr_review/README.md | 39 ++++++++----------- .../02_pr_review/agent_script.py | 30 ++++++-------- 3 files changed, 30 insertions(+), 43 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 5d8f7b4126..bfc64dd520 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -102,14 +102,14 @@ runs: run: | echo "Mode: $MODE" - # GITHUB_TOKEN is required for both modes + # GITHUB_TOKEN is required for both modes (PR diff and posting comments) if [ -z "$GITHUB_TOKEN" ]; then echo "Error: github-token is required for both modes." exit 1 fi if [ "$MODE" = "sdk" ]; then - # SDK mode requires LLM_API_KEY + # SDK mode also requires LLM_API_KEY if [ -z "$LLM_API_KEY" ]; then echo "Error: llm-api-key is required for 'sdk' mode." exit 1 diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index e885413338..feaff0dcb5 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -56,13 +56,14 @@ Set the following secrets in your GitHub repository settings based on your chose **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 **For Cloud Mode:** -- **`LLM_API_KEY`** (required): Your LLM API key (sent to the cloud sandbox) - **`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 for PR diff and posting "review started" comment -**Note**: The workflow automatically uses the `GITHUB_TOKEN` secret that's available in all GitHub Actions workflows. In cloud mode, this token is passed to the cloud sandbox so the agent can post review comments. +**Note**: In cloud mode, you don't need `LLM_API_KEY` - OpenHands Cloud uses your account's configured LLM. The agent running in cloud also has `GITHUB_TOKEN` automatically available for posting the final review. ### 3. Customize the workflow (optional) @@ -96,32 +97,24 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. - name: Run PR Review uses: ./.github/actions/pr-review with: - # Review mode: 'cloud' runs in OpenHands Cloud sandbox + # Review mode: 'cloud' runs in OpenHands Cloud mode: cloud - # LLM configuration (sent to cloud sandbox) - llm-model: anthropic/claude-sonnet-4-5-20250929 # Review style: roasted (other option: standard) review-style: roasted # SDK git ref to use sdk-version: main - # Secrets for cloud mode - llm-api-key: ${{ secrets.LLM_API_KEY }} + # Cloud mode secrets openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} # Optional: custom cloud API URL # openhands-cloud-api-url: https://app.all-hands.dev - # GitHub token - passed to cloud sandbox for posting review comments - github-token: ${{ secrets.GITHUB_TOKEN }} ``` **Cloud Mode Benefits:** -- Runs in a fully managed cloud sandbox environment -- No local Docker or infrastructure needed -- Same capabilities as SDK mode but in the cloud - -**Cloud Mode Architecture:** -- Uses [OpenHandsCloudWorkspace](https://docs.openhands.dev/sdk/guides/agent-server/cloud-workspace) to provision a sandbox -- LLM configuration and GITHUB_TOKEN are sent to the cloud sandbox -- The agent runs in the cloud and posts review comments directly +- **No LLM setup**: Uses your OpenHands Cloud account's configured LLM +- **Faster CI completion**: Starts the review and exits immediately +- **Track progress in UI**: View the review at the conversation URL +- **Agent has GitHub access**: GITHUB_TOKEN is auto-available for the agent to post reviews ### 4. Create the review label @@ -225,16 +218,16 @@ 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 (used in both modes) | No | `anthropic/claude-sonnet-4-5-20250929` | -| `llm-base-url` | LLM base URL (optional, for custom LLM endpoints) | No | `''` | +| `llm-model` | LLM model (sdk mode only) | No | `anthropic/claude-sonnet-4-5-20250929` | +| `llm-base-url` | LLM base URL (sdk mode only) | 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 (required for both modes) | Yes | - | -| `github-token` | GitHub token for API access (required for both modes) | Yes | - | -| `openhands-cloud-api-key` | OpenHands Cloud API key (required for cloud mode) | No | - | +| `llm-api-key` | LLM API key (sdk mode only) | sdk mode | - | +| `github-token` | GitHub token for API access | Yes | - | +| `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 (optional) | No | - | +| `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 234648c1c4..c4bb95a967 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -23,7 +23,7 @@ LLM_API_KEY: API key for the LLM (required for 'sdk' mode only) 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) + GITHUB_TOKEN: GitHub token for API access (required for both modes) PR_NUMBER: Pull request number (required) PR_TITLE: Pull request title (required) PR_BODY: Pull request body (optional) @@ -37,9 +37,10 @@ Note on 'cloud' mode: - Creates a conversation directly via OpenHands Cloud API - No LLM credentials needed - uses the user's cloud-configured LLM +- GITHUB_TOKEN needed for PR diff and posting "started" comment +- Agent in cloud has GITHUB_TOKEN automatically available for posting reviews - The agent runs asynchronously in the cloud (non-blocking) - Posts a comment on the PR with a link to track progress in the UI -- Agent is instructed to post a review comment on the PR when done For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. @@ -184,21 +185,12 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: # Additional instruction for cloud mode to post review comment when done -# Note: The GITHUB_TOKEN is provided directly in the instruction since cloud API -# doesn't support passing secrets separately +# Note: GITHUB_TOKEN is automatically available in OpenHands Cloud environments CLOUD_MODE_INSTRUCTION = """ -IMPORTANT CREDENTIALS: -The following GitHub token has been provided for API access: -GITHUB_TOKEN={github_token} - -You can use this token with curl or the GitHub API directly. For example: -```bash -export GITHUB_TOKEN="{github_token}" -``` - IMPORTANT: When you have completed the code review, you MUST post a summary comment -on the PR using the GitHub API. Use the following curl command: +on the PR using the GitHub API. The GITHUB_TOKEN environment variable is already +available in the cloud environment. Use the following curl command: ```bash curl -X POST \\ @@ -278,11 +270,13 @@ def main(): logger.info(f"Mode: {mode}") # Validate required environment variables based on mode + # Both modes need GITHUB_TOKEN for fetching PR diff and posting comments # Cloud mode doesn't need LLM_API_KEY - uses user's cloud-configured LLM + # Note: The agent running in cloud mode has GITHUB_TOKEN auto-available if mode == "cloud": required_vars = [ "OPENHANDS_CLOUD_API_KEY", - "GITHUB_TOKEN", + "GITHUB_TOKEN", # Needed for PR diff and posting "started" comment "PR_NUMBER", "PR_TITLE", "PR_BASE_BRANCH", @@ -305,7 +299,7 @@ def main(): logger.error(f"Missing required environment variables: {missing_vars}") sys.exit(1) - # Get credentials based on mode + # Get credentials github_token = _get_required_env("GITHUB_TOKEN") api_key = os.getenv("LLM_API_KEY") # May be None in cloud mode @@ -354,11 +348,11 @@ def main(): # Handle cloud mode - uses OpenHands Cloud API directly if mode == "cloud": - # Add instruction with GitHub token for API access + # Add instruction to post review comment when done + # Note: GITHUB_TOKEN is automatically available in cloud environments prompt += CLOUD_MODE_INSTRUCTION.format( repo_name=pr_info["repo_name"], pr_number=pr_info["number"], - github_token=github_token, ) cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") From 8efbed0b732a93d8e1e6dc5f62c0ccc8ed8ac8c1 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 09:34:01 +0000 Subject: [PATCH 10/19] fix: Cloud mode no longer requires GITHUB_TOKEN In cloud mode, the agent has access to both LLM and GitHub credentials via the user's OpenHands Cloud account configuration. This means: - Only OPENHANDS_CLOUD_API_KEY is required for cloud mode - No LLM_API_KEY needed - uses user's cloud-configured LLM - No GITHUB_TOKEN needed - agent uses user's cloud-configured GitHub credentials - Agent fetches PR diff and posts review comments using its own GitHub access The CLOUD_MODE_PROMPT now instructs the agent to: 1. Fetch the PR diff using `gh pr diff` 2. Analyze the changes 3. Post review comments using the GitHub API This simplifies the workflow configuration significantly for cloud mode users. Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 22 +-- .../02_pr_review/README.md | 13 +- .../02_pr_review/agent_script.py | 134 ++++++++++-------- 3 files changed, 91 insertions(+), 78 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index bfc64dd520..8dcd5c3ad7 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -37,8 +37,9 @@ inputs: required: false default: '' github-token: - description: GitHub token for API access (required for both modes) - required: true + description: GitHub token for API access (required for 'sdk' mode only, cloud mode uses user's configured credentials) + required: false + default: '' 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 @@ -102,20 +103,19 @@ runs: run: | echo "Mode: $MODE" - # GITHUB_TOKEN is required for both modes (PR diff and posting comments) - if [ -z "$GITHUB_TOKEN" ]; then - echo "Error: github-token is required for both modes." - exit 1 - fi - if [ "$MODE" = "sdk" ]; then - # SDK mode also requires LLM_API_KEY + # SDK mode requires LLM_API_KEY and GITHUB_TOKEN if [ -z "$LLM_API_KEY" ]; then echo "Error: llm-api-key is required for 'sdk' mode." exit 1 fi + if [ -z "$GITHUB_TOKEN" ]; then + echo "Error: github-token is required for 'sdk' mode." + exit 1 + fi elif [ "$MODE" = "cloud" ]; then - # Cloud mode requires OPENHANDS_CLOUD_API_KEY (LLM is configured in cloud) + # Cloud mode only requires OPENHANDS_CLOUD_API_KEY + # LLM and GitHub credentials are configured in the user's cloud account if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then echo "Error: openhands-cloud-api-key is required for 'cloud' mode." exit 1 @@ -136,7 +136,7 @@ runs: fi else echo "OpenHands Cloud API URL: ${{ inputs.openhands-cloud-api-url }}" - echo "Note: LLM is configured in your OpenHands Cloud account" + echo "Note: LLM and GitHub credentials are configured in your OpenHands Cloud account" fi - name: Run PR review diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index feaff0dcb5..cf74011621 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -61,9 +61,8 @@ Set the following secrets in your GitHub repository settings based on your chose **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 for PR diff and posting "review started" comment -**Note**: In cloud mode, you don't need `LLM_API_KEY` - OpenHands Cloud uses your account's configured LLM. The agent running in cloud also has `GITHUB_TOKEN` automatically available for posting the final review. +**Note**: In cloud mode, you don't need `LLM_API_KEY` or `GITHUB_TOKEN` - OpenHands Cloud uses your account's configured LLM and GitHub credentials. The agent has full access to fetch the PR diff and post review comments. ### 3. Customize the workflow (optional) @@ -103,18 +102,18 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. review-style: roasted # SDK git ref to use sdk-version: main - # Cloud mode secrets + # Cloud mode only needs the API key - LLM and GitHub are configured in your account openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} - github-token: ${{ secrets.GITHUB_TOKEN }} # Optional: custom cloud API URL # openhands-cloud-api-url: https://app.all-hands.dev ``` **Cloud Mode Benefits:** +- **Minimal configuration**: Only needs `OPENHANDS_CLOUD_API_KEY` - **No LLM setup**: Uses your OpenHands Cloud account's configured LLM +- **No GITHUB_TOKEN needed**: Agent uses your cloud-configured GitHub credentials - **Faster CI completion**: Starts the review and exits immediately -- **Track progress in UI**: View the review at the conversation URL -- **Agent has GitHub access**: GITHUB_TOKEN is auto-available for the agent to post reviews +- **Track progress in UI**: View the review at the conversation URL printed in logs ### 4. Create the review label @@ -224,7 +223,7 @@ This workflow uses a reusable composite action located at `.github/actions/pr-re | `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 (sdk mode only) | sdk mode | - | -| `github-token` | GitHub token for API access | Yes | - | +| `github-token` | GitHub token (sdk mode only) | sdk mode | - | | `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 | - | 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 c4bb95a967..b78414b51a 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -23,7 +23,7 @@ LLM_API_KEY: API key for the LLM (required for 'sdk' mode only) 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 for both modes) + GITHUB_TOKEN: GitHub token for API access (required for 'sdk' mode only) PR_NUMBER: Pull request number (required) PR_TITLE: Pull request title (required) PR_BODY: Pull request body (optional) @@ -37,10 +37,9 @@ Note on 'cloud' mode: - Creates a conversation directly via OpenHands Cloud API - No LLM credentials needed - uses the user's cloud-configured LLM -- GITHUB_TOKEN needed for PR diff and posting "started" comment -- Agent in cloud has GITHUB_TOKEN automatically available for posting reviews +- No GITHUB_TOKEN needed - agent has access via user's cloud-configured credentials - The agent runs asynchronously in the cloud (non-blocking) -- Posts a comment on the PR with a link to track progress in the UI +- Agent fetches PR diff and posts review comments using its GitHub access For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. @@ -184,25 +183,42 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: raise RuntimeError(f"GitHub comment API request failed: {e.reason}") from e -# Additional instruction for cloud mode to post review comment when done +# Prompt template for cloud mode - agent fetches the PR diff itself # Note: GITHUB_TOKEN is automatically available in OpenHands Cloud environments -CLOUD_MODE_INSTRUCTION = """ +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 using the GitHub API. The GITHUB_TOKEN environment variable is already -available in the cloud environment. Use the following curl command: +on the PR. You can use the `gh` CLI: ```bash -curl -X POST \\ - -H "Authorization: Bearer $GITHUB_TOKEN" \\ - -H "Accept: application/vnd.github.v3+json" \\ - -H "Content-Type: application/json" \\ - "https://api.github.com/repos/{repo_name}/issues/{pr_number}/comments" \\ - -d '{{"body": "## Code Review Complete\\n\\n"}}' +gh pr comment {pr_number} --repo {repo_name} --body "## Code Review Complete + +" ``` Replace `` with a brief summary of your review findings. -This is required because the review is running asynchronously in the cloud. """ @@ -270,13 +286,12 @@ def main(): logger.info(f"Mode: {mode}") # Validate required environment variables based on mode - # Both modes need GITHUB_TOKEN for fetching PR diff and posting comments - # Cloud mode doesn't need LLM_API_KEY - uses user's cloud-configured LLM - # Note: The agent running in cloud mode has GITHUB_TOKEN auto-available + # Cloud mode only needs OPENHANDS_CLOUD_API_KEY: + # - LLM: uses user's cloud-configured LLM + # - GITHUB_TOKEN: agent has access via user's cloud-configured GitHub credentials if mode == "cloud": required_vars = [ "OPENHANDS_CLOUD_API_KEY", - "GITHUB_TOKEN", # Needed for PR diff and posting "started" comment "PR_NUMBER", "PR_TITLE", "PR_BASE_BRANCH", @@ -299,8 +314,8 @@ def main(): logger.error(f"Missing required environment variables: {missing_vars}") sys.exit(1) - # Get credentials - github_token = _get_required_env("GITHUB_TOKEN") + # Get credentials (GITHUB_TOKEN optional in cloud mode) + github_token = os.getenv("GITHUB_TOKEN") api_key = os.getenv("LLM_API_KEY") # May be None in cloud mode # Get PR information @@ -322,37 +337,24 @@ def main(): logger.info(f"Reviewing PR #{pr_info['number']}: {pr_info['title']}") logger.info(f"Review style: {review_style}") - try: - 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}") - - # Create the review prompt using the template - skill_trigger = ( - "/codereview" if review_style == "standard" else "/codereview-roasted" - ) - prompt = PROMPT.format( - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - repo_name=pr_info.get("repo_name", "N/A"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - pr_number=pr_info.get("number", "N/A"), - commit_id=commit_id, - skill_trigger=skill_trigger, - diff=pr_diff, - ) + # Determine skill trigger based on review style + skill_trigger = ( + "/codereview" if review_style == "standard" else "/codereview-roasted" + ) + try: # Handle cloud mode - uses OpenHands Cloud API directly + # No GITHUB_TOKEN needed - agent has access via user's cloud credentials if mode == "cloud": - # Add instruction to post review comment when done - # Note: GITHUB_TOKEN is automatically available in cloud environments - prompt += CLOUD_MODE_INSTRUCTION.format( - repo_name=pr_info["repo_name"], - pr_number=pr_info["number"], + # Create prompt for cloud mode - agent will fetch PR diff itself + prompt = CLOUD_MODE_PROMPT.format( + skill_trigger=skill_trigger, + repo_name=pr_info.get("repo_name", "N/A"), + pr_number=pr_info.get("number", "N/A"), + title=pr_info.get("title", "N/A"), + body=pr_info.get("body", "No description provided"), + base_branch=pr_info.get("base_branch", "main"), + head_branch=pr_info.get("head_branch", "N/A"), ) cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") @@ -364,7 +366,7 @@ def main(): logger.info(f"Using skill trigger: {skill_trigger}") # Create conversation via Cloud API - # This uses the user's cloud-configured LLM - no LLM credentials needed + # This uses the user's cloud-configured LLM and GitHub credentials conversation_id, conversation_url = _start_cloud_conversation( cloud_api_url=cloud_api_url, cloud_api_key=cloud_api_key, @@ -372,21 +374,33 @@ def main(): ) logger.info(f"Cloud conversation started: {conversation_id}") - - # Post a comment on the PR with the conversation 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 a review comment when the analysis is complete." - ) - post_github_comment(pr_info["repo_name"], pr_info["number"], comment_body) - logger.info(f"Cloud review URL: {conversation_url}") logger.info("Workflow complete - review continues in cloud") else: # SDK mode - run locally and wait for completion + # Requires GITHUB_TOKEN for fetching PR diff + + # Fetch PR diff for the prompt + 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}") + + # Create the review prompt using the template + prompt = PROMPT.format( + title=pr_info.get("title", "N/A"), + body=pr_info.get("body", "No description provided"), + repo_name=pr_info.get("repo_name", "N/A"), + base_branch=pr_info.get("base_branch", "main"), + head_branch=pr_info.get("head_branch", "N/A"), + pr_number=pr_info.get("number", "N/A"), + commit_id=commit_id, + skill_trigger=skill_trigger, + diff=pr_diff, + ) # Configure LLM for SDK mode model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") From c5f6d34a0a4ae9074d9910e8382882f02bbfad4b Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 10:19:02 +0000 Subject: [PATCH 11/19] fix: Restore initial comment posting in cloud mode Cloud mode now posts a comment on the PR with the conversation URL so users can track the review progress. This requires GITHUB_TOKEN for both modes: - SDK mode: Used for fetching PR diff and posting comments - Cloud mode: Used to post initial comment with conversation URL (agent has its own GitHub access for the actual review) Changes: - Restored post_github_comment() call in cloud mode - Made GITHUB_TOKEN required for both modes in validation - Updated docstrings and README to reflect the requirement Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 22 +++++++-------- .../02_pr_review/README.md | 12 ++++----- .../02_pr_review/agent_script.py | 27 ++++++++++++++----- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 8dcd5c3ad7..aa3a64529b 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -37,9 +37,8 @@ inputs: required: false default: '' github-token: - description: GitHub token for API access (required for 'sdk' mode only, cloud mode uses user's configured credentials) - required: false - default: '' + 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 @@ -103,19 +102,20 @@ runs: run: | 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 [ "$MODE" = "sdk" ]; then - # SDK mode requires LLM_API_KEY and GITHUB_TOKEN + # SDK mode also requires LLM_API_KEY if [ -z "$LLM_API_KEY" ]; then echo "Error: llm-api-key is required for 'sdk' mode." exit 1 fi - if [ -z "$GITHUB_TOKEN" ]; then - echo "Error: github-token is required for 'sdk' mode." - exit 1 - fi elif [ "$MODE" = "cloud" ]; then - # Cloud mode only requires OPENHANDS_CLOUD_API_KEY - # LLM and GitHub credentials are configured in the user's cloud account + # Cloud mode requires OPENHANDS_CLOUD_API_KEY (LLM is configured in cloud) if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then echo "Error: openhands-cloud-api-key is required for 'cloud' mode." exit 1 @@ -136,7 +136,7 @@ runs: fi else echo "OpenHands Cloud API URL: ${{ inputs.openhands-cloud-api-url }}" - echo "Note: LLM and GitHub credentials are configured in your OpenHands Cloud account" + echo "Note: LLM is configured in your OpenHands Cloud account" fi - name: Run PR review diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index cf74011621..2b228bb84f 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -61,8 +61,9 @@ Set the following secrets in your GitHub repository settings based on your chose **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 -**Note**: In cloud mode, you don't need `LLM_API_KEY` or `GITHUB_TOKEN` - OpenHands Cloud uses your account's configured LLM and GitHub credentials. The agent has full access to fetch the PR diff and post review comments. +**Note**: In cloud mode, you don't need `LLM_API_KEY` - OpenHands Cloud uses your account's configured LLM. The workflow uses `GITHUB_TOKEN` to post a comment linking to the conversation URL. The agent running in cloud has its own GitHub access for the actual review. ### 3. Customize the workflow (optional) @@ -102,18 +103,17 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. review-style: roasted # SDK git ref to use sdk-version: main - # Cloud mode only needs the API key - LLM and GitHub are configured in your account + # Cloud mode secrets openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} # Optional: custom cloud API URL # openhands-cloud-api-url: https://app.all-hands.dev ``` **Cloud Mode Benefits:** -- **Minimal configuration**: Only needs `OPENHANDS_CLOUD_API_KEY` - **No LLM setup**: Uses your OpenHands Cloud account's configured LLM -- **No GITHUB_TOKEN needed**: Agent uses your cloud-configured GitHub credentials - **Faster CI completion**: Starts the review and exits immediately -- **Track progress in UI**: View the review at the conversation URL printed in logs +- **Track progress in UI**: Posts a comment with a link to the conversation URL ### 4. Create the review label @@ -223,7 +223,7 @@ This workflow uses a reusable composite action located at `.github/actions/pr-re | `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 (sdk mode only) | sdk mode | - | -| `github-token` | GitHub token (sdk mode only) | sdk mode | - | +| `github-token` | GitHub token for API access | Yes | - | | `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 | - | 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 b78414b51a..d12657282c 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -23,7 +23,7 @@ LLM_API_KEY: API key for the LLM (required for 'sdk' mode only) 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 for 'sdk' mode only) + GITHUB_TOKEN: GitHub token for API access (required for both modes) PR_NUMBER: Pull request number (required) PR_TITLE: Pull request title (required) PR_BODY: Pull request body (optional) @@ -37,9 +37,10 @@ Note on 'cloud' mode: - Creates a conversation directly via OpenHands Cloud API - No LLM credentials needed - uses the user's cloud-configured LLM -- No GITHUB_TOKEN needed - agent has access via user's cloud-configured credentials +- GITHUB_TOKEN needed to post initial comment with conversation URL +- Agent has GitHub access via cloud credentials for the actual review - The agent runs asynchronously in the cloud (non-blocking) -- Agent fetches PR diff and posts review comments using its GitHub access +- Posts a comment on the PR with a link to track progress in the UI For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. @@ -286,12 +287,14 @@ def main(): logger.info(f"Mode: {mode}") # Validate required environment variables based on mode - # Cloud mode only needs OPENHANDS_CLOUD_API_KEY: + # Cloud mode needs OPENHANDS_CLOUD_API_KEY and GITHUB_TOKEN: # - LLM: uses user's cloud-configured LLM - # - GITHUB_TOKEN: agent has access via user's cloud-configured GitHub credentials + # - GITHUB_TOKEN: needed to post initial comment with conversation URL + # (agent has GitHub access via cloud credentials for the actual review) if mode == "cloud": required_vars = [ "OPENHANDS_CLOUD_API_KEY", + "GITHUB_TOKEN", # Needed to post initial "review started" comment "PR_NUMBER", "PR_TITLE", "PR_BASE_BRANCH", @@ -314,8 +317,8 @@ def main(): logger.error(f"Missing required environment variables: {missing_vars}") sys.exit(1) - # Get credentials (GITHUB_TOKEN optional in cloud mode) - github_token = os.getenv("GITHUB_TOKEN") + # Get credentials + github_token = _get_required_env("GITHUB_TOKEN") api_key = os.getenv("LLM_API_KEY") # May be None in cloud mode # Get PR information @@ -374,6 +377,16 @@ def main(): ) logger.info(f"Cloud conversation started: {conversation_id}") + + # Post a comment on the PR with the conversation 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) + logger.info(f"Cloud review URL: {conversation_url}") logger.info("Workflow complete - review continues in cloud") From 1a8b62b52d51573df84da1287bd1e3cdcfc8d08a Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 10:24:41 +0000 Subject: [PATCH 12/19] docs: Add cloud mode prerequisites about GitHub access The OpenHands Cloud account must have GitHub access to the target repository. Added warning and link to GitHub Installation Guide. Co-authored-by: openhands --- examples/03_github_workflows/02_pr_review/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index 2b228bb84f..e7ccb2153f 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -115,6 +115,11 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. - **Faster CI completion**: Starts the review and exits immediately - **Track progress in UI**: Posts a comment with a link to the conversation URL +**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: From 86b1243500d6659f2b4d993d44dfcba874473343 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 11:04:21 +0000 Subject: [PATCH 13/19] refactor: Use OpenHandsCloudWorkspace for cloud mode Refactored cloud mode to use the proper SDK pattern: - SDK mode: Runs agent locally with Conversation + LocalWorkspace - Cloud mode: Runs agent in cloud with Conversation + OpenHandsCloudWorkspace Both modes now use the same Agent, LLM, and Conversation pattern. The only difference is the workspace type. Changes: - Removed direct Cloud API calls (_start_cloud_conversation) - Cloud mode now uses OpenHandsCloudWorkspace context manager - Both modes require LLM_API_KEY (sent to cloud sandbox) - Both modes require GITHUB_TOKEN - Cloud mode additionally requires OPENHANDS_CLOUD_API_KEY Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 36 +- .../02_pr_review/README.md | 32 +- .../02_pr_review/agent_script.py | 484 +++++++----------- 3 files changed, 220 insertions(+), 332 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index aa3a64529b..fd12312570 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -33,11 +33,10 @@ inputs: required: false default: main llm-api-key: - description: LLM API key (required for 'sdk' mode only, cloud mode uses user's configured LLM) - required: false - default: '' + description: LLM API key (required for both modes) + required: true github-token: - description: GitHub token for API access (required for both modes - used to post initial comment in cloud mode) + description: GitHub token for API access (required for both modes) 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 @@ -102,24 +101,25 @@ runs: run: | echo "Mode: $MODE" - # GITHUB_TOKEN is required for both modes + # Both modes require LLM_API_KEY and GITHUB_TOKEN + if [ -z "$LLM_API_KEY" ]; then + echo "Error: llm-api-key is required." + exit 1 + fi if [ -z "$GITHUB_TOKEN" ]; then - echo "Error: github-token is required for both modes." + echo "Error: github-token is required." exit 1 fi if [ "$MODE" = "sdk" ]; then - # SDK mode also requires LLM_API_KEY - if [ -z "$LLM_API_KEY" ]; then - echo "Error: llm-api-key is required for 'sdk' mode." - exit 1 - fi + echo "Running in SDK mode (local execution)" elif [ "$MODE" = "cloud" ]; then - # Cloud mode requires OPENHANDS_CLOUD_API_KEY (LLM is configured in cloud) + # Cloud mode additionally requires OPENHANDS_CLOUD_API_KEY if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then echo "Error: openhands-cloud-api-key is required for 'cloud' mode." exit 1 fi + echo "Running in cloud mode (OpenHandsCloudWorkspace)" else echo "Error: mode must be 'sdk' or 'cloud', got '$MODE'." exit 1 @@ -129,14 +129,12 @@ runs: echo "PR Title: ${{ github.event.pull_request.title }}" echo "Repository: ${{ github.repository }}" echo "SDK Version: ${{ inputs.sdk-version }}" - 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 "LLM model: ${{ inputs.llm-model }}" + if [ -n "${{ inputs.llm-base-url }}" ]; then + echo "LLM base URL: ${{ inputs.llm-base-url }}" + fi + if [ "$MODE" = "cloud" ]; then 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 diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index e7ccb2153f..c790af050c 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -51,19 +51,18 @@ 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 based on your chosen mode: +Set the following secrets in your GitHub repository settings: -**For SDK Mode (default):** +**For Both Modes:** - **`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 -**For Cloud Mode:** +**For Cloud Mode (additionally):** - **`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 -**Note**: In cloud mode, you don't need `LLM_API_KEY` - OpenHands Cloud uses your account's configured LLM. The workflow uses `GITHUB_TOKEN` to post a comment linking to the conversation URL. The agent running in cloud has its own GitHub access for the actual review. +**Note**: Cloud mode runs the same agent as SDK mode, but in an OpenHands Cloud sandbox (using `OpenHandsCloudWorkspace`). Both modes require `LLM_API_KEY` because the LLM configuration is sent to the cloud sandbox. ### 3. Customize the workflow (optional) @@ -97,28 +96,25 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. - name: Run PR Review uses: ./.github/actions/pr-review with: - # Review mode: 'cloud' runs in OpenHands Cloud + # Review mode: 'cloud' runs in OpenHands Cloud sandbox mode: cloud + # LLM configuration (same as SDK mode) + llm-model: anthropic/claude-sonnet-4-5-20250929 # 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 }} + # Secrets (same as SDK mode, plus cloud API key) + llm-api-key: ${{ secrets.LLM_API_KEY }} github-token: ${{ secrets.GITHUB_TOKEN }} + openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} # Optional: custom cloud API URL # openhands-cloud-api-url: https://app.all-hands.dev ``` **Cloud Mode Benefits:** -- **No LLM setup**: Uses your OpenHands Cloud account's configured LLM -- **Faster CI completion**: Starts the review and exits immediately -- **Track progress in UI**: Posts a comment with a link to the conversation URL - -**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. +- **Managed sandbox**: Runs in OpenHands Cloud infrastructure +- **Same agent logic**: Uses exactly the same Agent and Conversation as SDK mode ### 4. Create the review label @@ -227,11 +223,11 @@ This workflow uses a reusable composite action located at `.github/actions/pr-re | `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 (sdk mode only) | sdk mode | - | +| `llm-api-key` | LLM API key | Yes | - | | `github-token` | GitHub token for API access | Yes | - | | `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 | - | +| `lmnr-api-key` | Laminar API key for observability | 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 d12657282c..05bd2ed2c5 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -18,12 +18,12 @@ Environment Variables: MODE: Review mode ('sdk' or 'cloud', default: 'sdk') - - 'sdk': Run the agent locally using the SDK (default) - - 'cloud': Run the agent in OpenHands Cloud (non-blocking) - LLM_API_KEY: API key for the LLM (required for 'sdk' mode only) + - 'sdk': Run the agent locally (no container) + - 'cloud': Run in OpenHands Cloud using OpenHandsCloudWorkspace + LLM_API_KEY: API key for the LLM (required) 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 for both modes) + GITHUB_TOKEN: GitHub token for API access (required) PR_NUMBER: Pull request number (required) PR_TITLE: Pull request title (required) PR_BODY: Pull request body (optional) @@ -35,12 +35,9 @@ OPENHANDS_CLOUD_API_URL: OpenHands Cloud API URL (default: https://app.all-hands.dev) Note on 'cloud' mode: -- Creates a conversation directly via OpenHands Cloud API -- No LLM credentials needed - uses the user's cloud-configured LLM -- GITHUB_TOKEN needed to post initial comment with conversation URL -- Agent has GitHub access via cloud credentials for the actual review -- The agent runs asynchronously in the cloud (non-blocking) -- Posts a comment on the PR with a link to track progress in the UI +- Uses OpenHandsCloudWorkspace to provision a sandbox in OpenHands Cloud +- Runs the same Agent and Conversation as SDK mode, just in a cloud sandbox +- The LLM configuration is sent to the cloud sandbox For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. @@ -54,11 +51,13 @@ from pathlib import Path from lmnr import Laminar +from pydantic import SecretStr from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger from openhands.sdk.conversation import get_agent_final_response from openhands.sdk.git.utils import run_git_command from openhands.tools.preset.default import get_default_condenser, get_default_tools +from openhands.workspace import OpenHandsCloudWorkspace # Add the script directory to Python path so we can import prompt.py @@ -184,94 +183,174 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: raise RuntimeError(f"GitHub comment API request failed: {e.reason}") from e -# Prompt template for cloud mode - agent fetches the PR diff itself -# Note: GITHUB_TOKEN is automatically available in OpenHands Cloud environments -CLOUD_MODE_PROMPT = """{skill_trigger} -/github-pr-review +def _run_review( + mode: str, + pr_info: dict, + skill_trigger: str, + review_style: str, + api_key: str, + github_token: str, +) -> None: + """Run the PR review conversation. -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 _start_cloud_conversation( - cloud_api_url: str, - cloud_api_key: str, - initial_message: str, -) -> tuple[str, str]: - """Start a conversation via OpenHands Cloud API. + Args: + mode: 'sdk' for local execution, 'cloud' for OpenHandsCloudWorkspace + pr_info: Dictionary with PR metadata + skill_trigger: The skill trigger to use (/codereview or /codereview-roasted) + review_style: Review style name for logging + api_key: LLM API key + github_token: GitHub token for API access + """ + # Fetch PR diff for the prompt + 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}") + + # Create the review prompt using the template + prompt = PROMPT.format( + title=pr_info.get("title", "N/A"), + body=pr_info.get("body", "No description provided"), + repo_name=pr_info.get("repo_name", "N/A"), + base_branch=pr_info.get("base_branch", "main"), + head_branch=pr_info.get("head_branch", "N/A"), + pr_number=pr_info.get("number", "N/A"), + commit_id=commit_id, + skill_trigger=skill_trigger, + diff=pr_diff, + ) - This creates a conversation directly through the Cloud API, which: - - Uses the user's cloud-configured LLM (no LLM credentials needed) - - Provisions a sandbox automatically - - Returns a conversation URL that works in the OpenHands Cloud UI + # Configure LLM + model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + base_url = os.getenv("LLM_BASE_URL") - Args: - cloud_api_url: OpenHands Cloud API URL (e.g., https://app.all-hands.dev) - cloud_api_key: API key for OpenHands Cloud - initial_message: The initial prompt to send to the agent + llm = LLM( + model=model, + api_key=SecretStr(api_key), + base_url=base_url or None, + usage_id="pr_review_agent", + drop_params=True, + ) - Returns: - Tuple of (conversation_id, conversation_url) + # Create AgentContext with public skills enabled + agent_context = AgentContext(load_public_skills=True) + + # Create agent with default tools + agent = 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"}) + ), + ) - Raises: - RuntimeError: If the API request fails - """ - url = f"{cloud_api_url}/api/conversations" + # Create secrets for masking + secrets = { + "LLM_API_KEY": api_key, + "GITHUB_TOKEN": github_token, + } - payload = {"initial_user_msg": initial_message} + logger.info("Starting PR review analysis...") + logger.info(f"Using skill trigger: {skill_trigger}") + logger.info("Agent will post inline review comments directly via GitHub API") - data = json.dumps(payload).encode("utf-8") - request = urllib.request.Request(url, data=data, method="POST") - request.add_header("Authorization", f"Bearer {cloud_api_key}") - request.add_header("Content-Type", "application/json") + if mode == "cloud": + # Cloud mode - use OpenHandsCloudWorkspace + cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") + cloud_api_url = os.getenv( + "OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev" + ) + logger.info(f"Using OpenHands Cloud: {cloud_api_url}") + + with OpenHandsCloudWorkspace( + cloud_api_url=cloud_api_url, + cloud_api_key=cloud_api_key, + ) as workspace: + conversation = Conversation( + agent=agent, + workspace=workspace, + secrets=secrets, + ) - try: - with urllib.request.urlopen(request, timeout=120) as response: - result = json.loads(response.read().decode("utf-8")) - except urllib.error.HTTPError as e: - details = (e.read() or b"").decode("utf-8", errors="replace").strip() - raise RuntimeError( - f"OpenHands Cloud API request failed: HTTP {e.code} {e.reason}. {details}" - ) from e - except urllib.error.URLError as e: - raise RuntimeError(f"OpenHands Cloud API request failed: {e.reason}") from e + conversation.send_message(prompt) + conversation.run() - conversation_id = result.get("conversation_id") - if not conversation_id: - raise RuntimeError(f"No conversation_id in response: {result}") + _log_conversation_results(conversation, pr_info, commit_id, review_style) + else: + # SDK mode - run locally + cwd = os.getcwd() + + conversation = Conversation( + agent=agent, + workspace=cwd, + secrets=secrets, + ) + + conversation.send_message(prompt) + conversation.run() + + _log_conversation_results(conversation, pr_info, commit_id, review_style) + + +def _log_conversation_results( + conversation, # LocalConversation or RemoteConversation + pr_info: dict, + commit_id: str, + review_style: str, +) -> None: + """Log conversation results and handle observability.""" + # 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 ID for delayed evaluation + trace_id = Laminar.get_trace_id() + if trace_id: + Laminar.set_trace_metadata( + { + "pr_number": pr_info["number"], + "repo_name": pr_info["repo_name"], + "workflow_phase": "review", + "review_style": review_style, + } + ) + + trace_data = { + "trace_id": str(trace_id), + "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}") + print("\n=== Laminar Trace ===") + print(f"Trace ID: {trace_id}") + + Laminar.flush() + else: + logger.warning("No Laminar trace ID found - observability may not be enabled") - conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" - return conversation_id, conversation_url + logger.info("PR review completed successfully") def main(): @@ -286,31 +365,20 @@ def main(): logger.info(f"Mode: {mode}") - # Validate required environment variables based on mode - # Cloud mode needs OPENHANDS_CLOUD_API_KEY and GITHUB_TOKEN: - # - LLM: uses user's cloud-configured LLM - # - GITHUB_TOKEN: needed to post initial comment with conversation URL - # (agent has GitHub access via cloud credentials for the actual review) + # Validate required environment variables + # Both modes need LLM_API_KEY and GITHUB_TOKEN + # Cloud mode additionally needs OPENHANDS_CLOUD_API_KEY + required_vars = [ + "LLM_API_KEY", + "GITHUB_TOKEN", + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] if mode == "cloud": - required_vars = [ - "OPENHANDS_CLOUD_API_KEY", - "GITHUB_TOKEN", # Needed to post initial "review started" comment - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] - else: - required_vars = [ - "LLM_API_KEY", - "GITHUB_TOKEN", - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] + required_vars.append("OPENHANDS_CLOUD_API_KEY") missing_vars = [var for var in required_vars if not os.getenv(var)] if missing_vars: @@ -319,7 +387,7 @@ def main(): # Get credentials github_token = _get_required_env("GITHUB_TOKEN") - api_key = os.getenv("LLM_API_KEY") # May be None in cloud mode + api_key = _get_required_env("LLM_API_KEY") # Get PR information pr_info = { @@ -346,188 +414,14 @@ def main(): ) try: - # Handle cloud mode - uses OpenHands Cloud API directly - # No GITHUB_TOKEN needed - agent has access via user's cloud credentials - if mode == "cloud": - # Create prompt for cloud mode - agent will fetch PR diff itself - prompt = CLOUD_MODE_PROMPT.format( - skill_trigger=skill_trigger, - repo_name=pr_info.get("repo_name", "N/A"), - pr_number=pr_info.get("number", "N/A"), - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - ) - - cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") - cloud_api_url = os.getenv( - "OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev" - ) - - logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") - logger.info(f"Using skill trigger: {skill_trigger}") - - # Create conversation via Cloud API - # This uses the user's cloud-configured LLM and GitHub credentials - conversation_id, conversation_url = _start_cloud_conversation( - cloud_api_url=cloud_api_url, - cloud_api_key=cloud_api_key, - initial_message=prompt, - ) - - logger.info(f"Cloud conversation started: {conversation_id}") - - # Post a comment on the PR with the conversation 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) - - logger.info(f"Cloud review URL: {conversation_url}") - logger.info("Workflow complete - review continues in cloud") - - else: - # SDK mode - run locally and wait for completion - # Requires GITHUB_TOKEN for fetching PR diff - - # Fetch PR diff for the prompt - 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}") - - # Create the review prompt using the template - prompt = PROMPT.format( - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - repo_name=pr_info.get("repo_name", "N/A"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - pr_number=pr_info.get("number", "N/A"), - commit_id=commit_id, - skill_trigger=skill_trigger, - diff=pr_diff, - ) - - # Configure LLM for SDK mode - 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) - - # Create AgentContext with public skills enabled - agent_context = AgentContext(load_public_skills=True) - - # Create agent with default tools - agent = 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"}) - ), - ) - - # Create secrets for masking - secrets = {} - if api_key: - secrets["LLM_API_KEY"] = api_key - if github_token: - secrets["GITHUB_TOKEN"] = github_token - - cwd = os.getcwd() - - 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 (blocking) - conversation.send_message(prompt) - conversation.run() - - # 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 ID for delayed evaluation - # When the PR is merged/closed, we can use this trace_id to evaluate - # how well the review comments were addressed. - # 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() - if trace_id: - # Set trace metadata for later retrieval and filtering - Laminar.set_trace_metadata( - { - "pr_number": pr_info["number"], - "repo_name": pr_info["repo_name"], - "workflow_phase": "review", - "review_style": review_style, - } - ) - - # Store trace_id in file for GitHub artifact upload - # This allows the evaluation workflow to link back to this trace - trace_data = { - "trace_id": str(trace_id), - "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}") - 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" - ) - - logger.info("PR review completed successfully") - + _run_review( + mode=mode, + pr_info=pr_info, + skill_trigger=skill_trigger, + review_style=review_style, + api_key=api_key, + github_token=github_token, + ) except Exception as e: logger.error(f"PR review failed: {e}") sys.exit(1) From 4cbb9101d8ad24870def2e46c763206b2c2c528a Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 11:17:12 +0000 Subject: [PATCH 14/19] revert: Restore Cloud API Direct approach for cloud mode Cloud mode should NOT require LLM_API_KEY - it uses the user's cloud-configured LLM via the /api/conversations endpoint. This reverts the refactoring to use OpenHandsCloudWorkspace, which required passing LLM configuration. Cloud mode: - Uses Cloud API Direct (/api/conversations endpoint) - No LLM_API_KEY needed (uses cloud-configured LLM) - Asynchronous (fire and forget) - Posts comment with conversation URL SDK mode: - Uses local workspace with Conversation + Agent - Requires LLM_API_KEY - Synchronous (waits for completion) Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 36 +- .../02_pr_review/README.md | 32 +- .../02_pr_review/agent_script.py | 484 +++++++++++------- 3 files changed, 332 insertions(+), 220 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index fd12312570..aa3a64529b 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -33,10 +33,11 @@ inputs: required: false default: main llm-api-key: - description: LLM API key (required for both modes) - required: true + description: LLM API key (required for 'sdk' mode only, cloud mode uses user's configured LLM) + required: false + default: '' github-token: - description: GitHub token for API access (required for both modes) + 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 @@ -101,25 +102,24 @@ runs: run: | echo "Mode: $MODE" - # Both modes require LLM_API_KEY and GITHUB_TOKEN - if [ -z "$LLM_API_KEY" ]; then - echo "Error: llm-api-key is required." - exit 1 - fi + # GITHUB_TOKEN is required for both modes if [ -z "$GITHUB_TOKEN" ]; then - echo "Error: github-token is required." + echo "Error: github-token is required for both modes." exit 1 fi if [ "$MODE" = "sdk" ]; then - echo "Running in SDK mode (local execution)" + # SDK mode also requires LLM_API_KEY + if [ -z "$LLM_API_KEY" ]; then + echo "Error: llm-api-key is required for 'sdk' mode." + exit 1 + fi elif [ "$MODE" = "cloud" ]; then - # Cloud mode additionally requires OPENHANDS_CLOUD_API_KEY + # Cloud mode requires OPENHANDS_CLOUD_API_KEY (LLM is configured in cloud) if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then echo "Error: openhands-cloud-api-key is required for 'cloud' mode." exit 1 fi - echo "Running in cloud mode (OpenHandsCloudWorkspace)" else echo "Error: mode must be 'sdk' or 'cloud', got '$MODE'." exit 1 @@ -129,12 +129,14 @@ 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 }}" - fi - if [ "$MODE" = "cloud" ]; then + 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 diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index c790af050c..e7ccb2153f 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -51,18 +51,19 @@ 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 Both Modes:** +**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 -**For Cloud Mode (additionally):** +**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 -**Note**: Cloud mode runs the same agent as SDK mode, but in an OpenHands Cloud sandbox (using `OpenHandsCloudWorkspace`). Both modes require `LLM_API_KEY` because the LLM configuration is sent to the cloud sandbox. +**Note**: In cloud mode, you don't need `LLM_API_KEY` - OpenHands Cloud uses your account's configured LLM. The workflow uses `GITHUB_TOKEN` to post a comment linking to the conversation URL. The agent running in cloud has its own GitHub access for the actual review. ### 3. Customize the workflow (optional) @@ -96,25 +97,28 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. - name: Run PR Review uses: ./.github/actions/pr-review with: - # Review mode: 'cloud' runs in OpenHands Cloud sandbox + # Review mode: 'cloud' runs in OpenHands Cloud mode: cloud - # LLM configuration (same as SDK mode) - llm-model: anthropic/claude-sonnet-4-5-20250929 # Review style: roasted (other option: standard) review-style: roasted # SDK git ref to use sdk-version: main - # Secrets (same as SDK mode, plus cloud API key) - llm-api-key: ${{ secrets.LLM_API_KEY }} - github-token: ${{ secrets.GITHUB_TOKEN }} + # Cloud mode secrets openhands-cloud-api-key: ${{ secrets.OPENHANDS_CLOUD_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} # Optional: custom cloud API URL # openhands-cloud-api-url: https://app.all-hands.dev ``` **Cloud Mode Benefits:** -- **Managed sandbox**: Runs in OpenHands Cloud infrastructure -- **Same agent logic**: Uses exactly the same Agent and Conversation as SDK mode +- **No LLM setup**: Uses your OpenHands Cloud account's configured LLM +- **Faster CI completion**: Starts the review and exits immediately +- **Track progress in UI**: Posts a comment with a link to the conversation URL + +**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 @@ -223,11 +227,11 @@ This workflow uses a reusable composite action located at `.github/actions/pr-re | `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 (sdk mode only) | sdk mode | - | | `github-token` | GitHub token for API access | Yes | - | | `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 | No | - | +| `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 05bd2ed2c5..d12657282c 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -18,12 +18,12 @@ Environment Variables: MODE: Review mode ('sdk' or 'cloud', default: 'sdk') - - 'sdk': Run the agent locally (no container) - - 'cloud': Run in OpenHands Cloud using OpenHandsCloudWorkspace - LLM_API_KEY: API key for the LLM (required) + - 'sdk': Run the agent locally using the SDK (default) + - 'cloud': Run the agent in OpenHands Cloud (non-blocking) + LLM_API_KEY: API key for the LLM (required for 'sdk' mode only) 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) + GITHUB_TOKEN: GitHub token for API access (required for both modes) PR_NUMBER: Pull request number (required) PR_TITLE: Pull request title (required) PR_BODY: Pull request body (optional) @@ -35,9 +35,12 @@ OPENHANDS_CLOUD_API_URL: OpenHands Cloud API URL (default: https://app.all-hands.dev) Note on 'cloud' mode: -- Uses OpenHandsCloudWorkspace to provision a sandbox in OpenHands Cloud -- Runs the same Agent and Conversation as SDK mode, just in a cloud sandbox -- The LLM configuration is sent to the cloud sandbox +- Creates a conversation directly via OpenHands Cloud API +- No LLM credentials needed - uses the user's cloud-configured LLM +- GITHUB_TOKEN needed to post initial comment with conversation URL +- Agent has GitHub access via cloud credentials for the actual review +- The agent runs asynchronously in the cloud (non-blocking) +- Posts a comment on the PR with a link to track progress in the UI For setup instructions, usage examples, and GitHub Actions integration, see README.md in this directory. @@ -51,13 +54,11 @@ from pathlib import Path from lmnr import Laminar -from pydantic import SecretStr from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger from openhands.sdk.conversation import get_agent_final_response from openhands.sdk.git.utils import run_git_command from openhands.tools.preset.default import get_default_condenser, get_default_tools -from openhands.workspace import OpenHandsCloudWorkspace # Add the script directory to Python path so we can import prompt.py @@ -183,174 +184,94 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: raise RuntimeError(f"GitHub comment API request failed: {e.reason}") from e -def _run_review( - mode: str, - pr_info: dict, - skill_trigger: str, - review_style: str, - api_key: str, - github_token: str, -) -> None: - """Run the PR review conversation. +# Prompt template for cloud mode - agent fetches the PR diff itself +# Note: GITHUB_TOKEN is automatically available in OpenHands Cloud environments +CLOUD_MODE_PROMPT = """{skill_trigger} +/github-pr-review - Args: - mode: 'sdk' for local execution, 'cloud' for OpenHandsCloudWorkspace - pr_info: Dictionary with PR metadata - skill_trigger: The skill trigger to use (/codereview or /codereview-roasted) - review_style: Review style name for logging - api_key: LLM API key - github_token: GitHub token for API access - """ - # Fetch PR diff for the prompt - 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}") - - # Create the review prompt using the template - prompt = PROMPT.format( - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - repo_name=pr_info.get("repo_name", "N/A"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - pr_number=pr_info.get("number", "N/A"), - commit_id=commit_id, - skill_trigger=skill_trigger, - diff=pr_diff, - ) +Review the PR and identify issues that need to be addressed. - # Configure LLM - model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") - base_url = os.getenv("LLM_BASE_URL") +## Pull Request Information +- **Repository**: {repo_name} +- **PR Number**: {pr_number} +- **Title**: {title} +- **Description**: {body} +- **Base Branch**: {base_branch} +- **Head Branch**: {head_branch} - llm = LLM( - model=model, - api_key=SecretStr(api_key), - base_url=base_url or None, - usage_id="pr_review_agent", - drop_params=True, - ) +## Instructions - # Create AgentContext with public skills enabled - agent_context = AgentContext(load_public_skills=True) - - # Create agent with default tools - agent = 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"}) - ), - ) +1. First, clone the repository and fetch the PR diff: + ```bash + gh pr diff {pr_number} --repo {repo_name} + ``` - # Create secrets for masking - secrets = { - "LLM_API_KEY": api_key, - "GITHUB_TOKEN": github_token, - } +2. Analyze the changes thoroughly - logger.info("Starting PR review analysis...") - logger.info(f"Using skill trigger: {skill_trigger}") - logger.info("Agent will post inline review comments directly via GitHub API") +3. Post your review using the GitHub API (GITHUB_TOKEN is already available) - if mode == "cloud": - # Cloud mode - use OpenHandsCloudWorkspace - cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") - cloud_api_url = os.getenv( - "OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev" - ) - logger.info(f"Using OpenHands Cloud: {cloud_api_url}") - - with OpenHandsCloudWorkspace( - cloud_api_url=cloud_api_url, - cloud_api_key=cloud_api_key, - ) as workspace: - conversation = Conversation( - agent=agent, - workspace=workspace, - secrets=secrets, - ) +IMPORTANT: When you have completed the code review, you MUST post a summary comment +on the PR. You can use the `gh` CLI: - conversation.send_message(prompt) - conversation.run() +```bash +gh pr comment {pr_number} --repo {repo_name} --body "## Code Review Complete - _log_conversation_results(conversation, pr_info, commit_id, review_style) - else: - # SDK mode - run locally - cwd = os.getcwd() - - conversation = Conversation( - agent=agent, - workspace=cwd, - secrets=secrets, - ) - - conversation.send_message(prompt) - conversation.run() - - _log_conversation_results(conversation, pr_info, commit_id, review_style) - - -def _log_conversation_results( - conversation, # LocalConversation or RemoteConversation - pr_info: dict, - commit_id: str, - review_style: str, -) -> None: - """Log conversation results and handle observability.""" - # 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 ID for delayed evaluation - trace_id = Laminar.get_trace_id() - if trace_id: - Laminar.set_trace_metadata( - { - "pr_number": pr_info["number"], - "repo_name": pr_info["repo_name"], - "workflow_phase": "review", - "review_style": review_style, - } - ) - - trace_data = { - "trace_id": str(trace_id), - "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}") - print("\n=== Laminar Trace ===") - print(f"Trace ID: {trace_id}") - - Laminar.flush() - else: - logger.warning("No Laminar trace ID found - observability may not be enabled") +" +``` + +Replace `` with a brief summary of your review findings. +""" + + +def _start_cloud_conversation( + cloud_api_url: str, + cloud_api_key: str, + initial_message: str, +) -> tuple[str, str]: + """Start a conversation via OpenHands Cloud API. + + This creates a conversation directly through the Cloud API, which: + - Uses the user's cloud-configured LLM (no LLM credentials needed) + - Provisions a sandbox automatically + - Returns a conversation URL that works in the OpenHands Cloud UI + + Args: + cloud_api_url: OpenHands Cloud API URL (e.g., https://app.all-hands.dev) + cloud_api_key: API key for OpenHands Cloud + initial_message: The initial prompt to send to the agent + + Returns: + Tuple of (conversation_id, conversation_url) + + Raises: + RuntimeError: If the API request fails + """ + url = f"{cloud_api_url}/api/conversations" + + payload = {"initial_user_msg": initial_message} + + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=data, method="POST") + request.add_header("Authorization", f"Bearer {cloud_api_key}") + request.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(request, timeout=120) as response: + result = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as e: + details = (e.read() or b"").decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"OpenHands Cloud API request failed: HTTP {e.code} {e.reason}. {details}" + ) from e + except urllib.error.URLError as e: + raise RuntimeError(f"OpenHands Cloud API request failed: {e.reason}") from e + + conversation_id = result.get("conversation_id") + if not conversation_id: + raise RuntimeError(f"No conversation_id in response: {result}") - logger.info("PR review completed successfully") + conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" + return conversation_id, conversation_url def main(): @@ -365,20 +286,31 @@ def main(): logger.info(f"Mode: {mode}") - # Validate required environment variables - # Both modes need LLM_API_KEY and GITHUB_TOKEN - # Cloud mode additionally needs OPENHANDS_CLOUD_API_KEY - required_vars = [ - "LLM_API_KEY", - "GITHUB_TOKEN", - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] + # Validate required environment variables based on mode + # Cloud mode needs OPENHANDS_CLOUD_API_KEY and GITHUB_TOKEN: + # - LLM: uses user's cloud-configured LLM + # - GITHUB_TOKEN: needed to post initial comment with conversation URL + # (agent has GitHub access via cloud credentials for the actual review) if mode == "cloud": - required_vars.append("OPENHANDS_CLOUD_API_KEY") + required_vars = [ + "OPENHANDS_CLOUD_API_KEY", + "GITHUB_TOKEN", # Needed to post initial "review started" comment + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] + else: + required_vars = [ + "LLM_API_KEY", + "GITHUB_TOKEN", + "PR_NUMBER", + "PR_TITLE", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] missing_vars = [var for var in required_vars if not os.getenv(var)] if missing_vars: @@ -387,7 +319,7 @@ def main(): # Get credentials github_token = _get_required_env("GITHUB_TOKEN") - api_key = _get_required_env("LLM_API_KEY") + api_key = os.getenv("LLM_API_KEY") # May be None in cloud mode # Get PR information pr_info = { @@ -414,14 +346,188 @@ def main(): ) try: - _run_review( - mode=mode, - pr_info=pr_info, - skill_trigger=skill_trigger, - review_style=review_style, - api_key=api_key, - github_token=github_token, - ) + # Handle cloud mode - uses OpenHands Cloud API directly + # No GITHUB_TOKEN needed - agent has access via user's cloud credentials + if mode == "cloud": + # Create prompt for cloud mode - agent will fetch PR diff itself + prompt = CLOUD_MODE_PROMPT.format( + skill_trigger=skill_trigger, + repo_name=pr_info.get("repo_name", "N/A"), + pr_number=pr_info.get("number", "N/A"), + title=pr_info.get("title", "N/A"), + body=pr_info.get("body", "No description provided"), + base_branch=pr_info.get("base_branch", "main"), + head_branch=pr_info.get("head_branch", "N/A"), + ) + + cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") + cloud_api_url = os.getenv( + "OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev" + ) + + logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") + logger.info(f"Using skill trigger: {skill_trigger}") + + # Create conversation via Cloud API + # This uses the user's cloud-configured LLM and GitHub credentials + conversation_id, conversation_url = _start_cloud_conversation( + cloud_api_url=cloud_api_url, + cloud_api_key=cloud_api_key, + initial_message=prompt, + ) + + logger.info(f"Cloud conversation started: {conversation_id}") + + # Post a comment on the PR with the conversation 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) + + logger.info(f"Cloud review URL: {conversation_url}") + logger.info("Workflow complete - review continues in cloud") + + else: + # SDK mode - run locally and wait for completion + # Requires GITHUB_TOKEN for fetching PR diff + + # Fetch PR diff for the prompt + 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}") + + # Create the review prompt using the template + prompt = PROMPT.format( + title=pr_info.get("title", "N/A"), + body=pr_info.get("body", "No description provided"), + repo_name=pr_info.get("repo_name", "N/A"), + base_branch=pr_info.get("base_branch", "main"), + head_branch=pr_info.get("head_branch", "N/A"), + pr_number=pr_info.get("number", "N/A"), + commit_id=commit_id, + skill_trigger=skill_trigger, + diff=pr_diff, + ) + + # Configure LLM for SDK mode + 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) + + # Create AgentContext with public skills enabled + agent_context = AgentContext(load_public_skills=True) + + # Create agent with default tools + agent = 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"}) + ), + ) + + # Create secrets for masking + secrets = {} + if api_key: + secrets["LLM_API_KEY"] = api_key + if github_token: + secrets["GITHUB_TOKEN"] = github_token + + cwd = os.getcwd() + + 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 (blocking) + conversation.send_message(prompt) + conversation.run() + + # 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 ID for delayed evaluation + # When the PR is merged/closed, we can use this trace_id to evaluate + # how well the review comments were addressed. + # 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() + if trace_id: + # Set trace metadata for later retrieval and filtering + Laminar.set_trace_metadata( + { + "pr_number": pr_info["number"], + "repo_name": pr_info["repo_name"], + "workflow_phase": "review", + "review_style": review_style, + } + ) + + # Store trace_id in file for GitHub artifact upload + # This allows the evaluation workflow to link back to this trace + trace_data = { + "trace_id": str(trace_id), + "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}") + 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" + ) + + logger.info("PR review completed successfully") + except Exception as e: logger.error(f"PR review failed: {e}") sys.exit(1) From 0680a97d689a35290c1d6f2a3920fa4c6fc962a8 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 13:45:13 +0000 Subject: [PATCH 15/19] refactor: Address code review feedback for PR review cloud mode - Extract common HTTP helper (_make_http_request) to reduce duplication - Refactor main() into run_sdk_mode() and run_cloud_mode() functions - Add PRInfo TypedDict for type safety - Improve test quality with meaningful tests for actual functions - Trim module docstring to focus on API documentation - Remove unused openhands-workspace dependency from action.yml Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 2 +- .../02_pr_review/agent_script.py | 629 ++++++++---------- .../github_workflows/test_pr_review_agent.py | 513 +++++++++----- 3 files changed, 618 insertions(+), 526 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index aa3a64529b..8a3582a1bc 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -90,7 +90,7 @@ runs: - name: Install OpenHands dependencies shell: bash run: | - uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools ./software-agent-sdk/openhands-workspace lmnr + uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools lmnr - name: Check required configuration shell: bash 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 d12657282c..fa0946fddf 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -1,62 +1,28 @@ #!/usr/bin/env python3 -""" -Example: PR Review Agent - -This script runs OpenHands agent to review a pull request and provide -fine-grained review comments. The agent has full repository access and uses -bash commands to analyze changes in context and post detailed review feedback -directly via `gh` or the GitHub API. - -This example demonstrates how to use skills for code review: -- `/codereview` - Standard code review skill -- `/codereview-roasted` - Linus Torvalds style brutally honest review - -The agent posts inline review comments on specific lines of code using the -GitHub API, rather than posting one giant comment under the PR. - -Designed for use with GitHub Actions workflows triggered by PR labels. - -Environment Variables: - MODE: Review mode ('sdk' or 'cloud', default: 'sdk') - - 'sdk': Run the agent locally using the SDK (default) - - 'cloud': Run the agent in OpenHands Cloud (non-blocking) - LLM_API_KEY: API key for the LLM (required for 'sdk' mode only) - 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 for both modes) - PR_NUMBER: Pull request number (required) - PR_TITLE: Pull request title (required) - PR_BODY: Pull request body (optional) - PR_BASE_BRANCH: Base branch name (required) - 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) - -Note on 'cloud' mode: -- Creates a conversation directly via OpenHands Cloud API -- No LLM credentials needed - uses the user's cloud-configured LLM -- GITHUB_TOKEN needed to post initial comment with conversation URL -- Agent has GitHub access via cloud credentials for the actual review -- The agent runs asynchronously in the cloud (non-blocking) -- Posts a comment on the PR with a link to track progress in the UI - -For setup instructions, usage examples, and GitHub Actions integration, -see README.md in this directory. +"""PR Review Agent - Automated code review using OpenHands. + +Supports two modes: +- 'sdk': Run locally using the SDK (default) +- 'cloud': Run in OpenHands Cloud (non-blocking) + +See README.md for setup instructions and usage examples. """ +from __future__ import annotations + import json import os import sys import urllib.error import urllib.request from pathlib import Path +from typing import Any, TypedDict from lmnr import Laminar from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger from openhands.sdk.conversation import get_agent_final_response +from openhands.sdk.conversation.base import BaseConversation from openhands.sdk.git.utils import run_git_command from openhands.tools.preset.default import get_default_condenser, get_default_tools @@ -70,47 +36,94 @@ logger = get_logger(__name__) + +class PRInfo(TypedDict): + """Pull request information.""" + + number: str + title: str + body: str + repo_name: str + base_branch: str + head_branch: str + + # Maximum total diff size MAX_TOTAL_DIFF = 100000 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") return value -def get_pr_diff_via_github_api(pr_number: str) -> str: - """Fetch the PR diff exactly as GitHub renders it. +def _make_http_request( + url: str, + *, + method: str = "GET", + headers: dict[str, str] | None = None, + data: dict[str, Any] | None = None, + timeout: int = 60, + error_prefix: str = "HTTP request", +) -> bytes: + """Make an HTTP request and return the response body. + + Args: + url: The URL to request + method: HTTP method (GET, POST, etc.) + headers: Optional headers to add to the request + data: Optional JSON data to send (will be encoded) + timeout: Request timeout in seconds + error_prefix: Prefix for error messages - Uses the GitHub REST API "Get a pull request" endpoint with an `Accept` - header requesting diff output. + Returns: + Response body as bytes - This avoids depending on local git refs (often stale/missing in - `pull_request_target` checkouts). + Raises: + RuntimeError: If the request fails """ + request = urllib.request.Request(url, method=method) - repo = _get_required_env("REPO_NAME") - token = _get_required_env("GITHUB_TOKEN") + if headers: + for key, value in headers.items(): + request.add_header(key, value) - url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}" - request = urllib.request.Request(url) - request.add_header("Accept", "application/vnd.github.v3.diff") - request.add_header("Authorization", f"Bearer {token}") - request.add_header("X-GitHub-Api-Version", "2022-11-28") + if data is not None: + encoded_data = json.dumps(data).encode("utf-8") + request.data = encoded_data + if "Content-Type" not in (headers or {}): + request.add_header("Content-Type", "application/json") try: - with urllib.request.urlopen(request, timeout=60) as response: - data = response.read() + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() except urllib.error.HTTPError as e: details = (e.read() or b"").decode("utf-8", errors="replace").strip() raise RuntimeError( - f"GitHub diff API request failed: HTTP {e.code} {e.reason}. {details}" + f"{error_prefix} failed: HTTP {e.code} {e.reason}. {details}" ) from e except urllib.error.URLError as e: - raise RuntimeError(f"GitHub diff API request failed: {e.reason}") from e + raise RuntimeError(f"{error_prefix} failed: {e.reason}") from e + +def get_pr_diff_via_github_api(pr_number: str) -> str: + """Fetch the PR diff exactly as GitHub renders it.""" + repo = _get_required_env("REPO_NAME") + token = _get_required_env("GITHUB_TOKEN") + + url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}" + headers = { + "Accept": "application/vnd.github.v3.diff", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + + data = _make_http_request( + url, headers=headers, error_prefix="GitHub diff API request" + ) return data.decode("utf-8", errors="replace") @@ -155,33 +168,23 @@ def get_head_commit_sha(repo_dir: Path | None = None) -> str: def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: - """Post a comment on a GitHub PR. - - Args: - repo_name: Repository name in format owner/repo - pr_number: Pull request number - body: Comment body text - """ + """Post a comment on a GitHub PR.""" token = _get_required_env("GITHUB_TOKEN") url = f"https://api.github.com/repos/{repo_name}/issues/{pr_number}/comments" + headers = { + "Accept": "application/vnd.github.v3+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } - 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: - logger.info(f"Posted comment to PR #{pr_number}: {response.status}") - except urllib.error.HTTPError as e: - details = (e.read() or b"").decode("utf-8", errors="replace").strip() - raise RuntimeError( - f"GitHub comment API request failed: HTTP {e.code} {e.reason}. {details}" - ) from e - except urllib.error.URLError as e: - raise RuntimeError(f"GitHub comment API request failed: {e.reason}") from e + _make_http_request( + url, + method="POST", + headers=headers, + data={"body": body}, + error_prefix="GitHub comment API request", + ) + logger.info(f"Posted comment to PR #{pr_number}") # Prompt template for cloud mode - agent fetches the PR diff itself @@ -230,42 +233,22 @@ def _start_cloud_conversation( ) -> tuple[str, str]: """Start a conversation via OpenHands Cloud API. - This creates a conversation directly through the Cloud API, which: - - Uses the user's cloud-configured LLM (no LLM credentials needed) - - Provisions a sandbox automatically - - Returns a conversation URL that works in the OpenHands Cloud UI - - Args: - cloud_api_url: OpenHands Cloud API URL (e.g., https://app.all-hands.dev) - cloud_api_key: API key for OpenHands Cloud - initial_message: The initial prompt to send to the agent - Returns: Tuple of (conversation_id, conversation_url) - - Raises: - RuntimeError: If the API request fails """ url = f"{cloud_api_url}/api/conversations" + headers = {"Authorization": f"Bearer {cloud_api_key}"} + + response_data = _make_http_request( + url, + method="POST", + headers=headers, + data={"initial_user_msg": initial_message}, + timeout=120, + error_prefix="OpenHands Cloud API request", + ) - payload = {"initial_user_msg": initial_message} - - data = json.dumps(payload).encode("utf-8") - request = urllib.request.Request(url, data=data, method="POST") - request.add_header("Authorization", f"Bearer {cloud_api_key}") - request.add_header("Content-Type", "application/json") - - try: - with urllib.request.urlopen(request, timeout=120) as response: - result = json.loads(response.read().decode("utf-8")) - except urllib.error.HTTPError as e: - details = (e.read() or b"").decode("utf-8", errors="replace").strip() - raise RuntimeError( - f"OpenHands Cloud API request failed: HTTP {e.code} {e.reason}. {details}" - ) from e - except urllib.error.URLError as e: - raise RuntimeError(f"OpenHands Cloud API request failed: {e.reason}") from e - + result = json.loads(response_data.decode("utf-8")) conversation_id = result.get("conversation_id") if not conversation_id: raise RuntimeError(f"No conversation_id in response: {result}") @@ -274,260 +257,220 @@ def _start_cloud_conversation( return conversation_id, conversation_url -def main(): +def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: + """Run PR review in OpenHands Cloud (non-blocking).""" + 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"], + ) + + cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") + cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") + + logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") + logger.info(f"Using skill trigger: {skill_trigger}") + + conversation_id, conversation_url = _start_cloud_conversation( + cloud_api_url=cloud_api_url, + cloud_api_key=cloud_api_key, + initial_message=prompt, + ) + + logger.info(f"Cloud conversation started: {conversation_id}") + + 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) + + logger.info(f"Cloud review URL: {conversation_url}") + logger.info("Workflow complete - review continues in cloud") + + +def run_sdk_mode(pr_info: PRInfo, skill_trigger: str, review_style: str) -> None: + """Run PR review locally using the SDK (blocking).""" + pr_diff = get_truncated_pr_diff() + logger.info(f"Got PR diff with {len(pr_diff)} characters") + + commit_id = get_head_commit_sha() + logger.info(f"HEAD commit SHA: {commit_id}") + + prompt = PROMPT.format( + title=pr_info["title"], + body=pr_info["body"] or "No description provided", + repo_name=pr_info["repo_name"], + base_branch=pr_info["base_branch"], + head_branch=pr_info["head_branch"], + pr_number=pr_info["number"], + commit_id=commit_id, + skill_trigger=skill_trigger, + diff=pr_diff, + ) + + api_key = _get_required_env("LLM_API_KEY") + github_token = _get_required_env("GITHUB_TOKEN") + model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + base_url = os.getenv("LLM_BASE_URL") + + llm_config: dict[str, Any] = { + "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) + + agent = Agent( + llm=llm, + tools=get_default_tools(enable_browser=False), + agent_context=AgentContext(load_public_skills=True), + system_prompt_kwargs={"cli_mode": True}, + condenser=get_default_condenser( + llm=llm.model_copy(update={"usage_id": "condenser"}) + ), + ) + + conversation = Conversation( + agent=agent, + workspace=os.getcwd(), + secrets={"LLM_API_KEY": api_key, "GITHUB_TOKEN": github_token}, + ) + + logger.info("Starting PR review analysis...") + logger.info(f"Using skill trigger: {skill_trigger}") + + conversation.send_message(prompt) + conversation.run() + + review_content = get_agent_final_response(conversation.state.events) + if review_content: + logger.info(f"Agent final response: {len(review_content)} characters") + + _print_cost_summary(conversation) + _save_laminar_trace(pr_info, commit_id, review_style) + + logger.info("PR review completed successfully") + + +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: PRInfo, commit_id: str, review_style: str) -> None: + """Save Laminar trace info for delayed evaluation.""" + trace_id = Laminar.get_trace_id() + if not trace_id: + logger.warning("No Laminar trace ID found - observability may not be enabled") + return + + Laminar.set_trace_metadata( + { + "pr_number": pr_info["number"], + "repo_name": pr_info["repo_name"], + "workflow_phase": "review", + "review_style": review_style, + } + ) + + trace_data = { + "trace_id": str(trace_id), + "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}") + print("\n=== Laminar Trace ===") + print(f"Trace ID: {trace_id}") + + Laminar.flush() + + +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", + "PR_BASE_BRANCH", + "PR_HEAD_BRANCH", + "REPO_NAME", + ] + if mode == "cloud": + return ["OPENHANDS_CLOUD_API_KEY"] + common_vars + return ["LLM_API_KEY"] + common_vars + + +def _get_pr_info() -> PRInfo: + """Get PR information from environment variables.""" + return PRInfo( + 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...") - # Get mode 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}") - # Validate required environment variables based on mode - # Cloud mode needs OPENHANDS_CLOUD_API_KEY and GITHUB_TOKEN: - # - LLM: uses user's cloud-configured LLM - # - GITHUB_TOKEN: needed to post initial comment with conversation URL - # (agent has GitHub access via cloud credentials for the actual review) - if mode == "cloud": - required_vars = [ - "OPENHANDS_CLOUD_API_KEY", - "GITHUB_TOKEN", # Needed to post initial "review started" comment - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] - else: - required_vars = [ - "LLM_API_KEY", - "GITHUB_TOKEN", - "PR_NUMBER", - "PR_TITLE", - "PR_BASE_BRANCH", - "PR_HEAD_BRANCH", - "REPO_NAME", - ] - + 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) - # Get credentials - github_token = _get_required_env("GITHUB_TOKEN") - api_key = os.getenv("LLM_API_KEY") # May be None in cloud mode - - # 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}") - # Determine skill trigger based on review style skill_trigger = ( "/codereview" if review_style == "standard" else "/codereview-roasted" ) try: - # Handle cloud mode - uses OpenHands Cloud API directly - # No GITHUB_TOKEN needed - agent has access via user's cloud credentials if mode == "cloud": - # Create prompt for cloud mode - agent will fetch PR diff itself - prompt = CLOUD_MODE_PROMPT.format( - skill_trigger=skill_trigger, - repo_name=pr_info.get("repo_name", "N/A"), - pr_number=pr_info.get("number", "N/A"), - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - ) - - cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") - cloud_api_url = os.getenv( - "OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev" - ) - - logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") - logger.info(f"Using skill trigger: {skill_trigger}") - - # Create conversation via Cloud API - # This uses the user's cloud-configured LLM and GitHub credentials - conversation_id, conversation_url = _start_cloud_conversation( - cloud_api_url=cloud_api_url, - cloud_api_key=cloud_api_key, - initial_message=prompt, - ) - - logger.info(f"Cloud conversation started: {conversation_id}") - - # Post a comment on the PR with the conversation 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) - - logger.info(f"Cloud review URL: {conversation_url}") - logger.info("Workflow complete - review continues in cloud") - + run_cloud_mode(pr_info, skill_trigger) else: - # SDK mode - run locally and wait for completion - # Requires GITHUB_TOKEN for fetching PR diff - - # Fetch PR diff for the prompt - 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}") - - # Create the review prompt using the template - prompt = PROMPT.format( - title=pr_info.get("title", "N/A"), - body=pr_info.get("body", "No description provided"), - repo_name=pr_info.get("repo_name", "N/A"), - base_branch=pr_info.get("base_branch", "main"), - head_branch=pr_info.get("head_branch", "N/A"), - pr_number=pr_info.get("number", "N/A"), - commit_id=commit_id, - skill_trigger=skill_trigger, - diff=pr_diff, - ) - - # Configure LLM for SDK mode - 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) - - # Create AgentContext with public skills enabled - agent_context = AgentContext(load_public_skills=True) - - # Create agent with default tools - agent = 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"}) - ), - ) - - # Create secrets for masking - secrets = {} - if api_key: - secrets["LLM_API_KEY"] = api_key - if github_token: - secrets["GITHUB_TOKEN"] = github_token - - cwd = os.getcwd() - - 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 (blocking) - conversation.send_message(prompt) - conversation.run() - - # 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 ID for delayed evaluation - # When the PR is merged/closed, we can use this trace_id to evaluate - # how well the review comments were addressed. - # 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() - if trace_id: - # Set trace metadata for later retrieval and filtering - Laminar.set_trace_metadata( - { - "pr_number": pr_info["number"], - "repo_name": pr_info["repo_name"], - "workflow_phase": "review", - "review_style": review_style, - } - ) - - # Store trace_id in file for GitHub artifact upload - # This allows the evaluation workflow to link back to this trace - trace_data = { - "trace_id": str(trace_id), - "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}") - 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" - ) - - logger.info("PR review completed successfully") - + run_sdk_mode(pr_info, skill_trigger, review_style) except Exception as e: logger.error(f"PR review failed: {e}") sys.exit(1) diff --git a/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py index 223cb2ffe9..1dbd00a1c9 100644 --- a/tests/github_workflows/test_pr_review_agent.py +++ b/tests/github_workflows/test_pr_review_agent.py @@ -1,6 +1,13 @@ -"""Tests for PR review agent script.""" +"""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 json import sys +import urllib.error from pathlib import Path from unittest.mock import MagicMock, patch @@ -17,189 +24,331 @@ sys.path.insert(0, str(pr_review_path)) -def test_post_github_comment_success(): - """Test successful comment posting.""" - from agent_script import post_github_comment # type: ignore[import-not-found] +class TestMakeHttpRequest: + """Tests for the _make_http_request helper function.""" + + def test_get_request_success(self): + """Test successful GET request.""" + from agent_script import _make_http_request # type: ignore[import-not-found] + + mock_response = MagicMock() + mock_response.read.return_value = b'{"status": "ok"}' + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + with patch( + "urllib.request.urlopen", return_value=mock_response + ) as mock_urlopen: + result = _make_http_request( + "https://api.example.com/test", + headers={"Authorization": "Bearer token"}, + ) + + assert result == b'{"status": "ok"}' + mock_urlopen.assert_called_once() + request = mock_urlopen.call_args[0][0] + assert request.full_url == "https://api.example.com/test" + assert request.get_header("Authorization") == "Bearer token" + + def test_post_request_with_json_data(self): + """Test POST request with JSON data.""" + from agent_script import _make_http_request # type: ignore[import-not-found] + + mock_response = MagicMock() + mock_response.read.return_value = b'{"id": 123}' + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + with patch( + "urllib.request.urlopen", return_value=mock_response + ) as mock_urlopen: + result = _make_http_request( + "https://api.example.com/create", + method="POST", + data={"name": "test"}, + ) + + assert result == b'{"id": 123}' + request = mock_urlopen.call_args[0][0] + assert request.get_method() == "POST" + assert request.data == json.dumps({"name": "test"}).encode("utf-8") + assert request.get_header("Content-type") == "application/json" + + def test_http_error_handling(self): + """Test that HTTP errors are converted to RuntimeError.""" + from agent_script import _make_http_request # type: ignore[import-not-found] + + mock_error = urllib.error.HTTPError( + "https://api.example.com/test", + 404, + "Not Found", + {}, # type: ignore[arg-type] + None, + ) + mock_error.read = MagicMock(return_value=b"Resource not found") + + with ( + patch("urllib.request.urlopen", side_effect=mock_error), + pytest.raises(RuntimeError, match="Test API failed: HTTP 404"), + ): + _make_http_request( + "https://api.example.com/test", + error_prefix="Test API", + ) + + +class TestPostGithubComment: + """Tests for the post_github_comment function.""" + + def test_success(self): + """Test successful comment posting.""" + from agent_script import post_github_comment # type: ignore[import-not-found] + + mock_response = MagicMock() + mock_response.read.return_value = b'{"id": 123}' + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + 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() + request = mock_urlopen.call_args[0][0] + assert request.full_url == ( + "https://api.github.com/repos/owner/repo/issues/123/comments" + ) + assert request.get_header("Authorization") == "Bearer test-token" + + def test_missing_token_raises_error(self): + """Test that missing GITHUB_TOKEN raises ValueError.""" + from agent_script import post_github_comment # type: ignore[import-not-found] + + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(ValueError, match="GITHUB_TOKEN"), + ): + post_github_comment("owner/repo", "123", "Test comment") + + +class TestStartCloudConversation: + """Tests for the _start_cloud_conversation function.""" + + def test_success(self): + """Test successful cloud conversation creation.""" + from agent_script import ( # type: ignore[import-not-found] + _start_cloud_conversation, + ) + + mock_response = MagicMock() + mock_response.read.return_value = b'{"conversation_id": "abc123"}' + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_response): + conv_id, conv_url = _start_cloud_conversation( + "https://app.all-hands.dev", + "test-api-key", + "Hello, review this PR", + ) + + assert conv_id == "abc123" + assert conv_url == "https://app.all-hands.dev/conversations/abc123" + + def test_missing_conversation_id_raises_error(self): + """Test that missing conversation_id in response raises RuntimeError.""" + from agent_script import ( # type: ignore[import-not-found] + _start_cloud_conversation, + ) + + mock_response = MagicMock() + mock_response.read.return_value = b'{"error": "something went wrong"}' + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + + with ( + patch("urllib.request.urlopen", return_value=mock_response), + pytest.raises(RuntimeError, match="No conversation_id in response"), + ): + _start_cloud_conversation( + "https://app.all-hands.dev", + "test-api-key", + "Hello", + ) + + +class TestCloudModePrompt: + """Tests for the CLOUD_MODE_PROMPT template.""" + + def test_format_with_all_fields(self): + """Test that CLOUD_MODE_PROMPT formats correctly with all fields.""" + from agent_script import CLOUD_MODE_PROMPT # type: ignore[import-not-found] + + 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 + - mock_response = MagicMock() - mock_response.status = 201 - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) +class TestGetRequiredVarsForMode: + """Tests for the _get_required_vars_for_mode function.""" - 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") + 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, + ) - # Verify the request was made - mock_urlopen.assert_called_once() - call_args = mock_urlopen.call_args - request = call_args[0][0] + vars = _get_required_vars_for_mode("sdk") + assert "LLM_API_KEY" in vars + assert "OPENHANDS_CLOUD_API_KEY" not in vars - assert request.full_url == ( - "https://api.github.com/repos/owner/repo/issues/123/comments" + def test_cloud_mode_requires_cloud_api_key(self): + """Test that cloud mode requires OPENHANDS_CLOUD_API_KEY.""" + from agent_script import ( # type: ignore[import-not-found] + _get_required_vars_for_mode, ) - assert request.get_header("Authorization") == "Bearer test-token" - assert request.get_header("Content-type") == "application/json" - - -def test_post_github_comment_missing_token(): - """Test that missing GITHUB_TOKEN raises error.""" - from agent_script import post_github_comment # type: ignore[import-not-found] - - with ( - patch.dict("os.environ", {}, clear=True), - pytest.raises(ValueError, match="GITHUB_TOKEN"), - ): - post_github_comment("owner/repo", "123", "Test comment") - - -def test_cloud_mode_prompt_format(): - """Test that CLOUD_MODE_PROMPT can be formatted correctly.""" - from agent_script import ( # type: ignore[import-not-found] - CLOUD_MODE_PROMPT, - ) - - # Test that the prompt can be formatted without errors - 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", - ) - - # Verify the formatted prompt contains the expected values - assert "owner/repo" in formatted - assert "123" in formatted - assert "GITHUB_TOKEN" in formatted - assert "gh" in formatted # Uses gh CLI instead of curl - - -def test_mode_defaults_to_sdk(): - """Test that MODE defaults to 'sdk'.""" - import os - - # When MODE is not set, it should default to 'sdk' - with patch.dict("os.environ", {}, clear=False): - mode = os.getenv("MODE", "sdk").lower() - assert mode == "sdk" - - -def test_mode_cloud_accepted(): - """Test that 'cloud' is a valid MODE.""" - import os - - with patch.dict("os.environ", {"MODE": "cloud"}, clear=False): - mode = os.getenv("MODE", "sdk").lower() - assert mode == "cloud" - - -def test_mode_case_insensitive(): - """Test that MODE is case insensitive.""" - import os - - for value in ["CLOUD", "Cloud", "cLoUd"]: - with patch.dict("os.environ", {"MODE": value}, clear=False): - mode = os.getenv("MODE", "sdk").lower() - assert mode == "cloud" - - -def test_sdk_mode_requires_llm_api_key(): - """Test that SDK mode fails without LLM_API_KEY.""" - from agent_script import main # type: ignore[import-not-found] - - # Set up minimal environment for SDK mode but missing LLM_API_KEY - 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", - # LLM_API_KEY intentionally missing - } - - 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_requires_cloud_api_key(): - """Test that cloud mode fails without OPENHANDS_CLOUD_API_KEY.""" - from agent_script import main # type: ignore[import-not-found] - - # Set up environment for cloud mode but missing OPENHANDS_CLOUD_API_KEY - # Note: Cloud mode does NOT require LLM_API_KEY - uses cloud-configured LLM - 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", - # OPENHANDS_CLOUD_API_KEY intentionally missing - } - - 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_require_github_token(): - """Test that both modes require GITHUB_TOKEN.""" - from agent_script import main # type: ignore[import-not-found] - - # Test SDK mode without GITHUB_TOKEN - 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", - # GITHUB_TOKEN intentionally missing - } - - with ( - patch.dict("os.environ", sdk_env, clear=True), - pytest.raises(SystemExit) as exc_info, - ): - main() - - assert exc_info.value.code == 1 - - # Test cloud mode without GITHUB_TOKEN - # Note: Cloud mode does NOT require LLM_API_KEY - uses cloud-configured LLM - cloud_env = { - "MODE": "cloud", - "OPENHANDS_CLOUD_API_KEY": "test-key", - "PR_NUMBER": "123", - "PR_TITLE": "Test PR", - "PR_BASE_BRANCH": "main", - "PR_HEAD_BRANCH": "feature", - "REPO_NAME": "owner/repo", - # GITHUB_TOKEN intentionally missing - } - - with ( - patch.dict("os.environ", cloud_env, clear=True), - pytest.raises(SystemExit) as exc_info, - ): - main() - - assert exc_info.value.code == 1 + + vars = _get_required_vars_for_mode("cloud") + assert "OPENHANDS_CLOUD_API_KEY" in vars + 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] + + 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 + + cloud_env = { + "MODE": "cloud", + "OPENHANDS_CLOUD_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", cloud_env, clear=True), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 1 From eb86be2b65c30351ef2f6888c138f7c5a76c1019 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 13:57:08 +0000 Subject: [PATCH 16/19] refactor: Use OpenHandsCloudWorkspace for cloud mode PR review - Replace direct API calls to /api/conversations with OpenHandsCloudWorkspace - Cloud mode now uses the SDK's proper abstraction for cloud sandbox management - Cloud mode now requires both OPENHANDS_CLOUD_API_KEY and LLM_API_KEY (LLM config is sent to the cloud sandbox) - Update tests to reflect the new requirements Co-authored-by: openhands --- .../02_pr_review/agent_script.py | 119 +++++++++++------- .../github_workflows/test_pr_review_agent.py | 83 ++++++------ 2 files changed, 111 insertions(+), 91 deletions(-) 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 fa0946fddf..4dd1344ab3 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -3,7 +3,7 @@ Supports two modes: - 'sdk': Run locally using the SDK (default) -- 'cloud': Run in OpenHands Cloud (non-blocking) +- 'cloud': Run in OpenHands Cloud using OpenHandsCloudWorkspace See README.md for setup instructions and usage examples. """ @@ -19,12 +19,18 @@ from typing import Any, TypedDict from lmnr import Laminar +from pydantic import SecretStr from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger from openhands.sdk.conversation import get_agent_final_response from openhands.sdk.conversation.base import BaseConversation from openhands.sdk.git.utils import run_git_command -from openhands.tools.preset.default import get_default_condenser, get_default_tools +from openhands.tools.preset.default import ( + get_default_agent, + get_default_condenser, + get_default_tools, +) +from openhands.workspace import OpenHandsCloudWorkspace # Add the script directory to Python path so we can import prompt.py @@ -226,39 +232,13 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: """ -def _start_cloud_conversation( - cloud_api_url: str, - cloud_api_key: str, - initial_message: str, -) -> tuple[str, str]: - """Start a conversation via OpenHands Cloud API. +def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: + """Run PR review in OpenHands Cloud using OpenHandsCloudWorkspace. - Returns: - Tuple of (conversation_id, conversation_url) + This creates a cloud sandbox and runs the review conversation there. + The sandbox is kept alive after the workflow exits so the review can + continue asynchronously. """ - url = f"{cloud_api_url}/api/conversations" - headers = {"Authorization": f"Bearer {cloud_api_key}"} - - response_data = _make_http_request( - url, - method="POST", - headers=headers, - data={"initial_user_msg": initial_message}, - timeout=120, - error_prefix="OpenHands Cloud API request", - ) - - result = json.loads(response_data.decode("utf-8")) - conversation_id = result.get("conversation_id") - if not conversation_id: - raise RuntimeError(f"No conversation_id in response: {result}") - - conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" - return conversation_id, conversation_url - - -def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: - """Run PR review in OpenHands Cloud (non-blocking).""" prompt = CLOUD_MODE_PROMPT.format( skill_trigger=skill_trigger, repo_name=pr_info["repo_name"], @@ -272,27 +252,70 @@ def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") + # LLM configuration is required for OpenHandsCloudWorkspace + # The LLM config is sent to the cloud sandbox + llm_api_key = _get_required_env("LLM_API_KEY") + llm_model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + llm_base_url = os.getenv("LLM_BASE_URL") + logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") logger.info(f"Using skill trigger: {skill_trigger}") + logger.info(f"Using LLM model: {llm_model}") + + # Create LLM configuration + llm = LLM( + usage_id="pr_review_agent", + model=llm_model, + api_key=SecretStr(llm_api_key), + base_url=llm_base_url or None, + ) - conversation_id, conversation_url = _start_cloud_conversation( + # 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, - initial_message=prompt, - ) + keep_alive=True, + ) as workspace: + # Create agent with default tools + agent = get_default_agent(llm=llm, cli_mode=True) - logger.info(f"Cloud conversation started: {conversation_id}") + # Get GitHub token for the conversation secrets + github_token = _get_required_env("GITHUB_TOKEN") - 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) + # Create conversation + conversation = Conversation( + agent=agent, + workspace=workspace, + secrets={"LLM_API_KEY": llm_api_key, "GITHUB_TOKEN": github_token}, + ) + + # Send the initial message + conversation.send_message(prompt) + + # 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) + + # Run the conversation - this will execute the review + conversation.run() + + # Print cost summary + _print_cost_summary(conversation) - logger.info(f"Cloud review URL: {conversation_url}") - logger.info("Workflow complete - review continues in cloud") + logger.info(f"Cloud review URL: {conversation_url}") + logger.info("Cloud review completed") def run_sdk_mode(pr_info: PRInfo, skill_trigger: str, review_style: str) -> None: @@ -421,7 +444,9 @@ def _get_required_vars_for_mode(mode: str) -> list[str]: "REPO_NAME", ] if mode == "cloud": - return ["OPENHANDS_CLOUD_API_KEY"] + common_vars + # Cloud mode requires both OPENHANDS_CLOUD_API_KEY and LLM_API_KEY + # The LLM config is sent to the cloud sandbox + return ["OPENHANDS_CLOUD_API_KEY", "LLM_API_KEY"] + common_vars return ["LLM_API_KEY"] + common_vars diff --git a/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py index 1dbd00a1c9..cb79b32d67 100644 --- a/tests/github_workflows/test_pr_review_agent.py +++ b/tests/github_workflows/test_pr_review_agent.py @@ -133,50 +133,18 @@ def test_missing_token_raises_error(self): post_github_comment("owner/repo", "123", "Test comment") -class TestStartCloudConversation: - """Tests for the _start_cloud_conversation function.""" +class TestRunCloudMode: + """Tests for the run_cloud_mode function using OpenHandsCloudWorkspace.""" - def test_success(self): - """Test successful cloud conversation creation.""" - from agent_script import ( # type: ignore[import-not-found] - _start_cloud_conversation, - ) - - mock_response = MagicMock() - mock_response.read.return_value = b'{"conversation_id": "abc123"}' - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) - - with patch("urllib.request.urlopen", return_value=mock_response): - conv_id, conv_url = _start_cloud_conversation( - "https://app.all-hands.dev", - "test-api-key", - "Hello, review this PR", - ) - - assert conv_id == "abc123" - assert conv_url == "https://app.all-hands.dev/conversations/abc123" - - def test_missing_conversation_id_raises_error(self): - """Test that missing conversation_id in response raises RuntimeError.""" + def test_cloud_mode_requires_llm_api_key(self): + """Test that cloud mode requires LLM_API_KEY.""" from agent_script import ( # type: ignore[import-not-found] - _start_cloud_conversation, + _get_required_vars_for_mode, ) - mock_response = MagicMock() - mock_response.read.return_value = b'{"error": "something went wrong"}' - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) - - with ( - patch("urllib.request.urlopen", return_value=mock_response), - pytest.raises(RuntimeError, match="No conversation_id in response"), - ): - _start_cloud_conversation( - "https://app.all-hands.dev", - "test-api-key", - "Hello", - ) + vars = _get_required_vars_for_mode("cloud") + assert "LLM_API_KEY" in vars + assert "OPENHANDS_CLOUD_API_KEY" in vars class TestCloudModePrompt: @@ -216,15 +184,17 @@ def test_sdk_mode_requires_llm_api_key(self): assert "LLM_API_KEY" in vars assert "OPENHANDS_CLOUD_API_KEY" not in vars - def test_cloud_mode_requires_cloud_api_key(self): - """Test that cloud mode requires OPENHANDS_CLOUD_API_KEY.""" + def test_cloud_mode_requires_both_api_keys(self): + """Test that cloud mode requires OPENHANDS_CLOUD_API_KEY and 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 - assert "LLM_API_KEY" not in vars + # Cloud mode now requires LLM_API_KEY because OpenHandsCloudWorkspace + # sends the LLM config to the cloud sandbox + assert "LLM_API_KEY" in vars def test_both_modes_require_github_token(self): """Test that both modes require GITHUB_TOKEN.""" @@ -297,6 +267,30 @@ def test_cloud_mode_fails_without_cloud_api_key(self): env = { "MODE": "cloud", + "LLM_API_KEY": "test-llm-key", + "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_llm_api_key(self): + """Test that cloud mode fails without LLM_API_KEY.""" + from agent_script import main # type: ignore[import-not-found] + + env = { + "MODE": "cloud", + "OPENHANDS_CLOUD_API_KEY": "test-cloud-key", "GITHUB_TOKEN": "test-token", "PR_NUMBER": "123", "PR_TITLE": "Test PR", @@ -337,7 +331,8 @@ def test_both_modes_fail_without_github_token(self): cloud_env = { "MODE": "cloud", - "OPENHANDS_CLOUD_API_KEY": "test-key", + "OPENHANDS_CLOUD_API_KEY": "test-cloud-key", + "LLM_API_KEY": "test-llm-key", "PR_NUMBER": "123", "PR_TITLE": "Test PR", "PR_BASE_BRANCH": "main", From bac9c5435af27bc77e2c8ac3fd25b253a27b8f04 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 14:20:21 +0000 Subject: [PATCH 17/19] fix: Address PR review feedback for cloud mode - Fix documentation/code mismatch: Update README.md and action.yml to clarify that cloud mode requires both OPENHANDS_CLOUD_API_KEY and LLM_API_KEY (the LLM config is sent to the cloud sandbox) - Replace _make_http_request with requests library for simpler HTTP handling - Add error handling for cloud mode: Post failure comment if conversation.run() fails after posting 'review started' comment - Add openhands-workspace package to action.yml dependencies (required for OpenHandsCloudWorkspace) - Update tests to work with requests library Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 19 ++- .../02_pr_review/README.md | 16 +- .../02_pr_review/agent_script.py | 87 +++------- .../github_workflows/test_pr_review_agent.py | 157 +++++++++--------- 4 files changed, 119 insertions(+), 160 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 8a3582a1bc..d27769ba4f 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -33,7 +33,7 @@ inputs: required: false default: main llm-api-key: - description: LLM API key (required for 'sdk' mode only, cloud mode uses user's configured LLM) + description: LLM API key (required for both 'sdk' and 'cloud' modes - cloud mode sends LLM config to the sandbox) required: false default: '' github-token: @@ -90,7 +90,7 @@ 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 @@ -108,18 +108,21 @@ runs: exit 1 fi + # LLM_API_KEY is required for both modes + if [ -z "$LLM_API_KEY" ]; then + echo "Error: llm-api-key is required for both 'sdk' and 'cloud' modes." + exit 1 + fi + if [ "$MODE" = "sdk" ]; then - # SDK mode also requires LLM_API_KEY - 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 (LLM is configured in cloud) + # Cloud mode also requires OPENHANDS_CLOUD_API_KEY if [ -z "$OPENHANDS_CLOUD_API_KEY" ]; then echo "Error: openhands-cloud-api-key is required for 'cloud' mode." exit 1 fi + echo "Cloud mode: LLM config will be sent to cloud sandbox" else echo "Error: mode must be 'sdk' or 'cloud', got '$MODE'." exit 1 diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index e7ccb2153f..0cd1faa207 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -61,9 +61,11 @@ Set the following secrets in your GitHub repository settings based on your chose **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) +- **`LLM_API_KEY`** (required): Your LLM API key (sent to the cloud sandbox) + - Get one from the [OpenHands LLM Provider](https://docs.all-hands.dev/openhands/usage/llms/openhands-llms) - **`GITHUB_TOKEN`** (auto-available): Used to post initial comment with conversation URL -**Note**: In cloud mode, you don't need `LLM_API_KEY` - OpenHands Cloud uses your account's configured LLM. The workflow uses `GITHUB_TOKEN` to post a comment linking to the conversation URL. The agent running in cloud has its own GitHub access for the actual review. +**Note**: Cloud mode requires both `OPENHANDS_CLOUD_API_KEY` and `LLM_API_KEY`. The LLM configuration is sent to the cloud sandbox where the agent runs. The workflow uses `GITHUB_TOKEN` to post a comment linking to the conversation URL. The agent running in cloud has its own GitHub access for the actual review. ### 3. Customize the workflow (optional) @@ -99,21 +101,25 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. with: # Review mode: 'cloud' runs in OpenHands Cloud mode: cloud + # LLM configuration (sent to cloud sandbox) + llm-model: anthropic/claude-sonnet-4-5-20250929 + llm-base-url: '' # 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 }} + llm-api-key: ${{ secrets.LLM_API_KEY }} github-token: ${{ secrets.GITHUB_TOKEN }} # Optional: custom cloud API URL # openhands-cloud-api-url: https://app.all-hands.dev ``` **Cloud Mode Benefits:** -- **No LLM setup**: Uses your OpenHands Cloud account's configured LLM - **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. @@ -222,12 +228,12 @@ 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 (sdk mode only) | No | `anthropic/claude-sonnet-4-5-20250929` | -| `llm-base-url` | LLM base URL (sdk mode only) | No | `''` | +| `llm-model` | LLM model to use | No | `anthropic/claude-sonnet-4-5-20250929` | +| `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 (sdk mode only) | sdk mode | - | +| `llm-api-key` | LLM API key (required for both modes) | Yes | - | | `github-token` | GitHub token for API access | Yes | - | | `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` | 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 4dd1344ab3..de782aa9de 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -13,11 +13,10 @@ import json import os import sys -import urllib.error -import urllib.request from pathlib import Path from typing import Any, TypedDict +import requests from lmnr import Laminar from pydantic import SecretStr @@ -66,55 +65,6 @@ def _get_required_env(name: str) -> str: return value -def _make_http_request( - url: str, - *, - method: str = "GET", - headers: dict[str, str] | None = None, - data: dict[str, Any] | None = None, - timeout: int = 60, - error_prefix: str = "HTTP request", -) -> bytes: - """Make an HTTP request and return the response body. - - Args: - url: The URL to request - method: HTTP method (GET, POST, etc.) - headers: Optional headers to add to the request - data: Optional JSON data to send (will be encoded) - timeout: Request timeout in seconds - error_prefix: Prefix for error messages - - Returns: - Response body as bytes - - Raises: - RuntimeError: If the request fails - """ - request = urllib.request.Request(url, method=method) - - if headers: - for key, value in headers.items(): - request.add_header(key, value) - - if data is not None: - encoded_data = json.dumps(data).encode("utf-8") - request.data = encoded_data - if "Content-Type" not in (headers or {}): - request.add_header("Content-Type", "application/json") - - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - return response.read() - except urllib.error.HTTPError as e: - details = (e.read() or b"").decode("utf-8", errors="replace").strip() - raise RuntimeError( - f"{error_prefix} failed: HTTP {e.code} {e.reason}. {details}" - ) from e - except urllib.error.URLError as e: - raise RuntimeError(f"{error_prefix} failed: {e.reason}") from e - - def get_pr_diff_via_github_api(pr_number: str) -> str: """Fetch the PR diff exactly as GitHub renders it.""" repo = _get_required_env("REPO_NAME") @@ -127,10 +77,9 @@ def get_pr_diff_via_github_api(pr_number: str) -> str: "X-GitHub-Api-Version": "2022-11-28", } - data = _make_http_request( - url, headers=headers, error_prefix="GitHub diff API request" - ) - return data.decode("utf-8", errors="replace") + response = requests.get(url, headers=headers, timeout=60) + response.raise_for_status() + return response.text def truncate_text(diff_text: str, max_total: int = MAX_TOTAL_DIFF) -> str: @@ -183,13 +132,8 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: "X-GitHub-Api-Version": "2022-11-28", } - _make_http_request( - url, - method="POST", - headers=headers, - data={"body": body}, - error_prefix="GitHub comment API request", - ) + response = requests.post(url, headers=headers, json={"body": body}, timeout=60) + response.raise_for_status() logger.info(f"Posted comment to PR #{pr_number}") @@ -309,13 +253,28 @@ def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: post_github_comment(pr_info["repo_name"], pr_info["number"], comment_body) # Run the conversation - this will execute the review - conversation.run() + try: + conversation.run() + logger.info("Cloud review completed") + except Exception as e: + # Post failure comment so user knows the review failed + error_comment = ( + f"❌ **OpenHands PR Review Failed**\n\n" + f"The code review encountered an error: `{e}`\n\n" + f"📍 **Check logs:** [{conversation_url}]({conversation_url})" + ) + try: + post_github_comment( + pr_info["repo_name"], pr_info["number"], error_comment + ) + except Exception as comment_error: + logger.warning(f"Failed to post error comment: {comment_error}") + raise # Print cost summary _print_cost_summary(conversation) logger.info(f"Cloud review URL: {conversation_url}") - logger.info("Cloud review completed") def run_sdk_mode(pr_info: PRInfo, skill_trigger: str, review_style: str) -> None: diff --git a/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py index cb79b32d67..64e35bc84c 100644 --- a/tests/github_workflows/test_pr_review_agent.py +++ b/tests/github_workflows/test_pr_review_agent.py @@ -5,13 +5,12 @@ suppressed with type: ignore comments. """ -import json import sys -import urllib.error from pathlib import Path from unittest.mock import MagicMock, patch import pytest +import requests # Add the PR review example directory to the path for imports @@ -24,79 +23,6 @@ sys.path.insert(0, str(pr_review_path)) -class TestMakeHttpRequest: - """Tests for the _make_http_request helper function.""" - - def test_get_request_success(self): - """Test successful GET request.""" - from agent_script import _make_http_request # type: ignore[import-not-found] - - mock_response = MagicMock() - mock_response.read.return_value = b'{"status": "ok"}' - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) - - with patch( - "urllib.request.urlopen", return_value=mock_response - ) as mock_urlopen: - result = _make_http_request( - "https://api.example.com/test", - headers={"Authorization": "Bearer token"}, - ) - - assert result == b'{"status": "ok"}' - mock_urlopen.assert_called_once() - request = mock_urlopen.call_args[0][0] - assert request.full_url == "https://api.example.com/test" - assert request.get_header("Authorization") == "Bearer token" - - def test_post_request_with_json_data(self): - """Test POST request with JSON data.""" - from agent_script import _make_http_request # type: ignore[import-not-found] - - mock_response = MagicMock() - mock_response.read.return_value = b'{"id": 123}' - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) - - with patch( - "urllib.request.urlopen", return_value=mock_response - ) as mock_urlopen: - result = _make_http_request( - "https://api.example.com/create", - method="POST", - data={"name": "test"}, - ) - - assert result == b'{"id": 123}' - request = mock_urlopen.call_args[0][0] - assert request.get_method() == "POST" - assert request.data == json.dumps({"name": "test"}).encode("utf-8") - assert request.get_header("Content-type") == "application/json" - - def test_http_error_handling(self): - """Test that HTTP errors are converted to RuntimeError.""" - from agent_script import _make_http_request # type: ignore[import-not-found] - - mock_error = urllib.error.HTTPError( - "https://api.example.com/test", - 404, - "Not Found", - {}, # type: ignore[arg-type] - None, - ) - mock_error.read = MagicMock(return_value=b"Resource not found") - - with ( - patch("urllib.request.urlopen", side_effect=mock_error), - pytest.raises(RuntimeError, match="Test API failed: HTTP 404"), - ): - _make_http_request( - "https://api.example.com/test", - error_prefix="Test API", - ) - - class TestPostGithubComment: """Tests for the post_github_comment function.""" @@ -105,22 +31,24 @@ def test_success(self): from agent_script import post_github_comment # type: ignore[import-not-found] mock_response = MagicMock() - mock_response.read.return_value = b'{"id": 123}' - mock_response.__enter__ = MagicMock(return_value=mock_response) - mock_response.__exit__ = MagicMock(return_value=False) + mock_response.status_code = 201 + mock_response.raise_for_status = MagicMock() with ( patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}, clear=False), - patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen, + patch( + "agent_script.requests.post", return_value=mock_response + ) as mock_post, ): post_github_comment("owner/repo", "123", "Test comment body") - mock_urlopen.assert_called_once() - request = mock_urlopen.call_args[0][0] - assert request.full_url == ( + mock_post.assert_called_once() + call_args = mock_post.call_args + assert call_args[0][0] == ( "https://api.github.com/repos/owner/repo/issues/123/comments" ) - assert request.get_header("Authorization") == "Bearer test-token" + assert call_args[1]["headers"]["Authorization"] == "Bearer test-token" + assert call_args[1]["json"] == {"body": "Test comment body"} def test_missing_token_raises_error(self): """Test that missing GITHUB_TOKEN raises ValueError.""" @@ -132,6 +60,69 @@ def test_missing_token_raises_error(self): ): post_github_comment("owner/repo", "123", "Test comment") + def test_http_error_raises_exception(self): + """Test that HTTP errors are raised.""" + from agent_script import post_github_comment # type: ignore[import-not-found] + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.raise_for_status.side_effect = requests.HTTPError("Not Found") + + with ( + patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}, clear=False), + patch("agent_script.requests.post", return_value=mock_response), + pytest.raises(requests.HTTPError), + ): + 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.text = "diff --git a/file.py b/file.py\n+new line" + mock_response.raise_for_status = MagicMock() + + env = {"REPO_NAME": "owner/repo", "GITHUB_TOKEN": "test-token"} + + with ( + patch.dict("os.environ", env, clear=False), + patch("agent_script.requests.get", return_value=mock_response) as mock_get, + ): + result = get_pr_diff_via_github_api("123") + + assert result == "diff --git a/file.py b/file.py\n+new line" + mock_get.assert_called_once() + call_args = mock_get.call_args + assert ( + call_args[0][0] == "https://api.github.com/repos/owner/repo/pulls/123" + ) + assert call_args[1]["headers"]["Accept"] == "application/vnd.github.v3.diff" + + def test_http_error_raises_exception(self): + """Test that HTTP errors are raised.""" + from agent_script import ( # type: ignore[import-not-found] + get_pr_diff_via_github_api, + ) + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("Not Found") + + env = {"REPO_NAME": "owner/repo", "GITHUB_TOKEN": "test-token"} + + with ( + patch.dict("os.environ", env, clear=False), + patch("agent_script.requests.get", return_value=mock_response), + pytest.raises(requests.HTTPError), + ): + get_pr_diff_via_github_api("123") + class TestRunCloudMode: """Tests for the run_cloud_mode function using OpenHandsCloudWorkspace.""" From e25cc17434c21b4dc666bd9769996d91a439125c Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 10 Feb 2026 16:09:46 +0000 Subject: [PATCH 18/19] fix: PR review cloud mode - exit after starting, make LLM_API_KEY optional Address review feedback from PR #1966: 1. Cloud mode with keep_alive=True now exits immediately after starting the conversation by using run(blocking=False). This provides the intended 'faster CI completion' benefit where the workflow exits immediately while the review continues asynchronously in the cloud. 2. LLM_API_KEY is now optional for cloud mode - the cloud uses the user's configured LLM settings from their OpenHands Cloud account. SDK mode still requires LLM_API_KEY for local execution. Changes: - agent_script.py: Use run(blocking=False) for cloud mode, make LLM_API_KEY optional with graceful handling - action.yml: Update validation to only require llm-api-key for SDK mode, update description to clarify - tests: Update tests to reflect new behavior - README.md: Update documentation to reflect LLM_API_KEY is optional for cloud mode Co-authored-by: openhands --- .github/actions/pr-review/action.yml | 17 +++--- .../02_pr_review/README.md | 14 ++--- .../02_pr_review/agent_script.py | 61 ++++++++----------- .../github_workflows/test_pr_review_agent.py | 43 +++---------- 4 files changed, 49 insertions(+), 86 deletions(-) diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index d27769ba4f..f69399a61d 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -33,7 +33,7 @@ inputs: required: false default: main llm-api-key: - description: LLM API key (required for both 'sdk' and 'cloud' modes - cloud mode sends LLM config to the sandbox) + description: LLM API key (required for 'sdk' mode only - cloud mode uses your OpenHands Cloud LLM settings) required: false default: '' github-token: @@ -108,21 +108,20 @@ runs: exit 1 fi - # LLM_API_KEY is required for both modes - if [ -z "$LLM_API_KEY" ]; then - echo "Error: llm-api-key is required for both 'sdk' and 'cloud' modes." - exit 1 - fi - 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 also requires OPENHANDS_CLOUD_API_KEY + # 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: LLM config will be sent to cloud sandbox" + 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 diff --git a/examples/03_github_workflows/02_pr_review/README.md b/examples/03_github_workflows/02_pr_review/README.md index 0cd1faa207..56f64db347 100644 --- a/examples/03_github_workflows/02_pr_review/README.md +++ b/examples/03_github_workflows/02_pr_review/README.md @@ -61,11 +61,10 @@ Set the following secrets in your GitHub repository settings based on your chose **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) -- **`LLM_API_KEY`** (required): Your LLM API key (sent to the cloud sandbox) - - Get one from the [OpenHands LLM Provider](https://docs.all-hands.dev/openhands/usage/llms/openhands-llms) - **`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 requires both `OPENHANDS_CLOUD_API_KEY` and `LLM_API_KEY`. The LLM configuration is sent to the cloud sandbox where the agent runs. The workflow uses `GITHUB_TOKEN` to post a comment linking to the conversation URL. The agent running in cloud has its own GitHub access for the actual review. +**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) @@ -101,17 +100,16 @@ Edit `.github/workflows/pr-review-by-openhands.yml` to customize the inputs. with: # Review mode: 'cloud' runs in OpenHands Cloud mode: cloud - # LLM configuration (sent to cloud sandbox) - llm-model: anthropic/claude-sonnet-4-5-20250929 - llm-base-url: '' # 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 }} - llm-api-key: ${{ secrets.LLM_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 ``` @@ -233,7 +231,7 @@ This workflow uses a reusable composite action located at `.github/actions/pr-re | `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 (required for both modes) | 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 | - | | `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` | 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 de782aa9de..e02f3ca3e3 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -179,9 +179,12 @@ def post_github_comment(repo_name: str, pr_number: str, body: str) -> None: def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: """Run PR review in OpenHands Cloud using OpenHandsCloudWorkspace. - This creates a cloud sandbox and runs the review conversation there. - The sandbox is kept alive after the workflow exits so the review can - continue asynchronously. + 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. + + Cloud mode uses the LLM configured in the user's OpenHands Cloud account, + so LLM_API_KEY is optional. If provided, it will be passed to the agent. """ prompt = CLOUD_MODE_PROMPT.format( skill_trigger=skill_trigger, @@ -196,21 +199,19 @@ def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: cloud_api_key = _get_required_env("OPENHANDS_CLOUD_API_KEY") cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") - # LLM configuration is required for OpenHandsCloudWorkspace - # The LLM config is sent to the cloud sandbox - llm_api_key = _get_required_env("LLM_API_KEY") + # LLM_API_KEY is optional for cloud mode - the cloud uses user's configured LLM + llm_api_key = os.getenv("LLM_API_KEY") llm_model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") llm_base_url = os.getenv("LLM_BASE_URL") logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") logger.info(f"Using skill trigger: {skill_trigger}") - logger.info(f"Using LLM model: {llm_model}") - # Create LLM configuration + # Create LLM configuration - api_key is optional for cloud mode llm = LLM( usage_id="pr_review_agent", model=llm_model, - api_key=SecretStr(llm_api_key), + api_key=SecretStr(llm_api_key) if llm_api_key else None, base_url=llm_base_url or None, ) @@ -227,11 +228,16 @@ def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: # Get GitHub token for the conversation secrets github_token = _get_required_env("GITHUB_TOKEN") + # 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 conversation = Conversation( agent=agent, workspace=workspace, - secrets={"LLM_API_KEY": llm_api_key, "GITHUB_TOKEN": github_token}, + secrets=secrets, ) # Send the initial message @@ -252,29 +258,11 @@ def run_cloud_mode(pr_info: PRInfo, skill_trigger: str) -> None: ) post_github_comment(pr_info["repo_name"], pr_info["number"], comment_body) - # Run the conversation - this will execute the review - try: - conversation.run() - logger.info("Cloud review completed") - except Exception as e: - # Post failure comment so user knows the review failed - error_comment = ( - f"❌ **OpenHands PR Review Failed**\n\n" - f"The code review encountered an error: `{e}`\n\n" - f"📍 **Check logs:** [{conversation_url}]({conversation_url})" - ) - try: - post_github_comment( - pr_info["repo_name"], pr_info["number"], error_comment - ) - except Exception as comment_error: - logger.warning(f"Failed to post error comment: {comment_error}") - raise - - # Print cost summary - _print_cost_summary(conversation) - - logger.info(f"Cloud review URL: {conversation_url}") + # Trigger the run with blocking=False so we exit immediately. + # With keep_alive=True, the cloud sandbox continues running the review + # asynchronously while this workflow exits. + conversation.run(blocking=False) + logger.info(f"Cloud review started (non-blocking): {conversation_url}") def run_sdk_mode(pr_info: PRInfo, skill_trigger: str, review_style: str) -> None: @@ -403,9 +391,10 @@ def _get_required_vars_for_mode(mode: str) -> list[str]: "REPO_NAME", ] if mode == "cloud": - # Cloud mode requires both OPENHANDS_CLOUD_API_KEY and LLM_API_KEY - # The LLM config is sent to the cloud sandbox - return ["OPENHANDS_CLOUD_API_KEY", "LLM_API_KEY"] + common_vars + # 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 diff --git a/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py index 64e35bc84c..000d4e18af 100644 --- a/tests/github_workflows/test_pr_review_agent.py +++ b/tests/github_workflows/test_pr_review_agent.py @@ -127,14 +127,14 @@ def test_http_error_raises_exception(self): class TestRunCloudMode: """Tests for the run_cloud_mode function using OpenHandsCloudWorkspace.""" - def test_cloud_mode_requires_llm_api_key(self): - """Test that cloud mode requires LLM_API_KEY.""" + 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" in vars + assert "LLM_API_KEY" not in vars assert "OPENHANDS_CLOUD_API_KEY" in vars @@ -175,17 +175,17 @@ def test_sdk_mode_requires_llm_api_key(self): assert "LLM_API_KEY" in vars assert "OPENHANDS_CLOUD_API_KEY" not in vars - def test_cloud_mode_requires_both_api_keys(self): - """Test that cloud mode requires OPENHANDS_CLOUD_API_KEY and LLM_API_KEY.""" + 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 now requires LLM_API_KEY because OpenHandsCloudWorkspace - # sends the LLM config to the cloud sandbox - assert "LLM_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.""" @@ -256,32 +256,9 @@ 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", - "LLM_API_KEY": "test-llm-key", - "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_llm_api_key(self): - """Test that cloud mode fails without LLM_API_KEY.""" - from agent_script import main # type: ignore[import-not-found] - - env = { - "MODE": "cloud", - "OPENHANDS_CLOUD_API_KEY": "test-cloud-key", "GITHUB_TOKEN": "test-token", "PR_NUMBER": "123", "PR_TITLE": "Test PR", @@ -320,10 +297,10 @@ def test_both_modes_fail_without_github_token(self): 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", - "LLM_API_KEY": "test-llm-key", "PR_NUMBER": "123", "PR_TITLE": "Test PR", "PR_BASE_BRANCH": "main", From 2e68717dfca540cf94805642a159c6c158d0c737 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 15 Feb 2026 10:09:56 +0000 Subject: [PATCH 19/19] refactor: Move PR review mode implementations to utils folder - Created utils/ folder with shared agent abstractions - utils/agent_util.py: Shared LLM, Agent, Conversation creation utilities - utils/sdk_mode.py: SDK implementation using shared utilities - utils/cloud_mode.py: Cloud implementation using OpenHandsCloudWorkspace - Updated agent_script.py to import from utils/ - Updated tests to import from new locations Co-authored-by: openhands --- .../02_pr_review/agent_script.py | 4 +- .../02_pr_review/utils/__init__.py | 0 .../{sdk_mode.py => utils/agent_util.py} | 129 +++++++++--------- .../02_pr_review/{ => utils}/cloud_mode.py | 51 +++---- .../02_pr_review/utils/sdk_mode.py | 76 +++++++++++ .../github_workflows/test_pr_review_agent.py | 12 +- 6 files changed, 172 insertions(+), 100 deletions(-) create mode 100644 examples/03_github_workflows/02_pr_review/utils/__init__.py rename examples/03_github_workflows/02_pr_review/{sdk_mode.py => utils/agent_util.py} (62%) rename examples/03_github_workflows/02_pr_review/{ => utils}/cloud_mode.py (84%) create mode 100644 examples/03_github_workflows/02_pr_review/utils/sdk_mode.py 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 46121b6a9e..a579bee025 100644 --- a/examples/03_github_workflows/02_pr_review/agent_script.py +++ b/examples/03_github_workflows/02_pr_review/agent_script.py @@ -751,9 +751,9 @@ def main() -> None: # Import and run the appropriate mode if mode == "cloud": - from cloud_mode import run_agent_review + from utils.cloud_mode import run_agent_review else: - from sdk_mode import run_agent_review + from utils.sdk_mode import run_agent_review run_agent_review( prompt=prompt, 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/sdk_mode.py b/examples/03_github_workflows/02_pr_review/utils/agent_util.py similarity index 62% rename from examples/03_github_workflows/02_pr_review/sdk_mode.py rename to examples/03_github_workflows/02_pr_review/utils/agent_util.py index 3cae3463df..308734e0e0 100644 --- a/examples/03_github_workflows/02_pr_review/sdk_mode.py +++ b/examples/03_github_workflows/02_pr_review/utils/agent_util.py @@ -1,70 +1,73 @@ -"""SDK Mode - Run PR review locally using the OpenHands SDK. +"""Shared agent utilities for PR review. -This module provides the SDK implementation for PR review, running the agent -locally with full control over the LLM configuration. +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 TYPE_CHECKING, Any +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.conversation.base import BaseConversation from openhands.tools.preset.default import get_default_condenser, get_default_tools -if TYPE_CHECKING: - from openhands.sdk.conversation.base import BaseConversation - 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). +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: - 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") + 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) - model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") - base_url = os.getenv("LLM_BASE_URL") + 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, - "api_key": api_key, "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 - llm = LLM(**llm_config) + 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. - cwd = os.getcwd() + 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(cwd) + project_skills = load_project_skills(workspace_path) logger.info( f"Loaded {len(project_skills)} project skills: " f"{[s.name for s in project_skills]}" @@ -77,7 +80,7 @@ def run_agent_review( ) # Create agent with default tools and agent context - agent = Agent( + return Agent( llm=llm, tools=get_default_tools(enable_browser=False), agent_context=agent_context, @@ -87,39 +90,33 @@ def run_agent_review( ), ) - # Create conversation with secrets for masking - secrets: dict[str, str] = {} - 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("Agent will post inline review comments directly via GitHub API") +def create_conversation( + agent: Agent, + workspace: Any, + secrets: dict[str, str] | None = None, +) -> BaseConversation: + """Create a Conversation instance. - # Send the prompt and run the agent - conversation.send_message(prompt) - conversation.run() - - # 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_summary(conversation) - _save_laminar_trace(pr_info, commit_id, review_style) + Args: + agent: Agent instance to use + workspace: Workspace path (str) or Workspace instance + secrets: Secrets to mask in agent output - logger.info("PR review completed successfully") + 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: +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 ===") @@ -134,8 +131,10 @@ def _print_cost_summary(conversation: BaseConversation) -> None: 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 +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() diff --git a/examples/03_github_workflows/02_pr_review/cloud_mode.py b/examples/03_github_workflows/02_pr_review/utils/cloud_mode.py similarity index 84% rename from examples/03_github_workflows/02_pr_review/cloud_mode.py rename to examples/03_github_workflows/02_pr_review/utils/cloud_mode.py index 3e06842fb8..a0e409614f 100644 --- a/examples/03_github_workflows/02_pr_review/cloud_mode.py +++ b/examples/03_github_workflows/02_pr_review/utils/cloud_mode.py @@ -7,17 +7,18 @@ from __future__ import annotations +import json import os import urllib.error import urllib.request from typing import Any -from pydantic import SecretStr - -from openhands.sdk import LLM, Conversation, get_logger -from openhands.tools.preset.default import get_default_agent +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__) @@ -64,7 +65,7 @@ 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, # noqa: ARG001 - unused, skill_trigger is derived from it + review_style: str, ) -> None: """Run PR review in OpenHands Cloud using OpenHandsCloudWorkspace. @@ -97,8 +98,6 @@ def run_agent_review( # LLM_API_KEY is optional for cloud mode - the cloud uses user's configured LLM llm_api_key = os.getenv("LLM_API_KEY") - llm_model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") - llm_base_url = os.getenv("LLM_BASE_URL") # Derive skill trigger from review style skill_trigger = ( @@ -119,13 +118,8 @@ def run_agent_review( logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") logger.info(f"Using skill trigger: {skill_trigger}") - # Create LLM configuration - api_key is optional for cloud mode - llm = LLM( - usage_id="pr_review_agent", - model=llm_model, - api_key=SecretStr(llm_api_key) if llm_api_key else None, - base_url=llm_base_url or None, - ) + # 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 @@ -134,24 +128,19 @@ def run_agent_review( cloud_api_key=cloud_api_key, keep_alive=True, ) as workspace: - # Create agent with default tools - agent = get_default_agent(llm=llm, cli_mode=True) + # 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 - conversation = Conversation( - agent=agent, - workspace=workspace, - secrets=secrets, + # Create conversation using shared utility + conversation = create_conversation( + agent=agent, workspace=workspace, secrets=secrets ) - # Send the initial message - conversation.send_message(cloud_prompt) - # Get conversation ID and construct URL conversation_id = str(conversation.id) conversation_url = f"{cloud_api_url}/conversations/{conversation_id}" @@ -167,10 +156,14 @@ def run_agent_review( ) _post_github_comment(pr_info["repo_name"], pr_info["number"], comment_body) - # Trigger the run with blocking=False so we exit immediately. - # With keep_alive=True, the cloud sandbox continues running the review - # asynchronously while this workflow exits. - conversation.run(blocking=False) + # 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}") @@ -182,8 +175,6 @@ def _post_github_comment(repo_name: str, pr_number: str, body: str) -> None: url = f"https://api.github.com/repos/{repo_name}/issues/{pr_number}/comments" - import json - data = json.dumps({"body": body}).encode("utf-8") request = urllib.request.Request(url, data=data, method="POST") 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/tests/github_workflows/test_pr_review_agent.py b/tests/github_workflows/test_pr_review_agent.py index 19ca5eb351..c0e6e6f3a6 100644 --- a/tests/github_workflows/test_pr_review_agent.py +++ b/tests/github_workflows/test_pr_review_agent.py @@ -27,7 +27,9 @@ class TestPostGithubComment: def test_success(self): """Test successful comment posting.""" - from cloud_mode import _post_github_comment # type: ignore[import-not-found] + from utils.cloud_mode import ( # type: ignore[import-not-found] + _post_github_comment, + ) mock_response = MagicMock() mock_response.read.return_value = b'{"id": 1}' @@ -42,7 +44,9 @@ def test_success(self): def test_missing_token_raises_error(self): """Test that missing GITHUB_TOKEN raises ValueError.""" - from cloud_mode import _post_github_comment # type: ignore[import-not-found] + from utils.cloud_mode import ( # type: ignore[import-not-found] + _post_github_comment, + ) with ( patch.dict("os.environ", {}, clear=True), @@ -96,7 +100,9 @@ class TestCloudModePrompt: def test_format_with_all_fields(self): """Test that CLOUD_MODE_PROMPT formats correctly with all fields.""" - from cloud_mode import CLOUD_MODE_PROMPT # type: ignore[import-not-found] + from utils.cloud_mode import ( # type: ignore[import-not-found] + CLOUD_MODE_PROMPT, + ) formatted = CLOUD_MODE_PROMPT.format( skill_trigger="/codereview",