Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API endpoints for resolver functionality #5058

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
73e190e
Add API endpoints for resolver functionality
openhands-agent Nov 15, 2024
fba35a4
Revert changes to send_pull_request.py
openhands-agent Nov 15, 2024
486355b
Improve process_single_issue return type and error handling
openhands-agent Nov 15, 2024
cb92518
Merge branch 'main' into add-resolver-api-endpoints
neubig Nov 15, 2024
66b4e5d
Fix failing tests in test_listen.py
openhands-agent Nov 15, 2024
abde56f
Update
neubig Nov 15, 2024
cfd3911
Refactor resolver endpoints to use data models (#5073)
neubig Nov 15, 2024
95884c1
Lint
neubig Nov 16, 2024
87925dd
Merge branch 'main' into add-resolver-api-endpoints
neubig Nov 16, 2024
c2265e8
Fix pr #5058: Add API endpoints for resolver functionality
openhands-agent Nov 16, 2024
a4f5772
Fix pr #5058: Add API endpoints for resolver functionality
openhands-agent Nov 16, 2024
27592c5
fix: Fix send-pr endpoint to use correct file path and update tests t…
openhands-agent Nov 16, 2024
f037482
Merge branch 'main' into add-resolver-api-endpoints
neubig Nov 16, 2024
031e201
feat: Combine resolve_issue and send_pull_request API calls into a si…
openhands-agent Nov 16, 2024
845f1b2
style: Fix linting issues
openhands-agent Nov 16, 2024
dbf560d
refactor: Improve resolver API endpoints
openhands-agent Nov 16, 2024
1f53c93
refactor: Remove unused SendPullRequestDataModel
openhands-agent Nov 16, 2024
45a1486
style: Format imports in listen.py
openhands-agent Nov 16, 2024
2ce806e
fix: Convert ResolverOutput to dict in response
openhands-agent Nov 16, 2024
c088a08
style: Fix linting in test_listen.py
openhands-agent Nov 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 183 additions & 21 deletions openhands/server/listen.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,10 @@
import time
import uuid
import warnings
from typing import Any, Literal

import jwt
import requests
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern

from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.security.options import SecurityAnalyzers
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.github import (
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
UserVerifier,
authenticate_github_user,
)
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_sync_from_async

with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm

from dotenv import load_dotenv
from fastapi import (
BackgroundTasks,
Expand All @@ -40,9 +22,10 @@
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import HTTPBearer
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
from pydantic import BaseModel, Field

import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands.controller.agent import Agent
from openhands.core.config import LLMConfig, load_app_config
from openhands.core.logger import openhands_logger as logger
Expand All @@ -62,10 +45,57 @@
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.llm import bedrock
from openhands.resolver.io_utils import load_single_resolver_output
from openhands.resolver.resolve_issue import resolve_issue as resolve_github_issue
from openhands.resolver.send_pull_request import process_single_issue
from openhands.runtime.base import Runtime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.security.options import SecurityAnalyzers
from openhands.server.auth.auth import get_sid_from_token, sign_token
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.github import (
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
UserVerifier,
authenticate_github_user,
)
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_sync_from_async

import openhands.agenthub # noqa F401 (we import this to get the agents registered)

with warnings.catch_warnings():
warnings.simplefilter('ignore')
import litellm


# Data models for resolver endpoints
class ResolveIssueRequest(BaseModel):
owner: str = Field(..., description='Github owner of the repo')
repo: str = Field(..., description='Github repository name')
token: str = Field(..., description='Github token to access the repository')
username: str = Field(..., description='Github username to access the repository')
max_iterations: int = Field(50, description='Maximum number of iterations to run')
issue_type: Literal['issue', 'pr'] = Field(
..., description='Type of issue to resolve (issue or pr)'
)
issue_number: int = Field(..., description='Issue number to resolve')
comment_id: int | None = Field(
None, description='Optional ID of a specific comment to focus on'
)


class SendPullRequestRequest(BaseModel):
issue_number: int = Field(..., description='Issue number to create PR for')
pr_type: Literal['branch', 'draft', 'ready'] = Field(
..., description='Type of PR to create (branch, draft, ready)'
)
fork_owner: str | None = Field(None, description='Optional owner to fork to')
send_on_failure: bool = Field(
False, description='Whether to send PR even if resolution failed'
)

load_dotenv()

Expand Down Expand Up @@ -452,6 +482,138 @@ async def get_security_analyzers():
return sorted(SecurityAnalyzers.keys())


@app.post('/api/resolver/resolve-issue')
async def resolve_issue(
request: Request, resolve_request: ResolveIssueRequest
) -> dict[str, str]:
"""Resolve a GitHub issue using OpenHands.

This endpoint attempts to automatically resolve a GitHub issue by:
1. Analyzing the issue content and comments
2. Making necessary code changes
3. Creating a pull request or branch with the changes

Args:
request: The incoming request object
resolve_request: The issue resolution request parameters

Returns:
A dictionary containing the resolution results with keys:
- status: 'success' or 'error'
- output: The output.jsonl contents (on success)
- message: Error message (on error)
"""
# Create temporary output directory
output_dir = tempfile.mkdtemp()

# Get LLM config from current session
llm_config = config.get_llm_config()

# Get runtime container image from config
runtime_container_image = config.sandbox.runtime_container_image
if not runtime_container_image:
raise ValueError('Runtime container image not configured')

# Get prompt template - for now using default
prompt_template = ''

try:
await resolve_github_issue(
owner=resolve_request.owner,
repo=resolve_request.repo,
token=resolve_request.token,
username=resolve_request.username,
max_iterations=resolve_request.max_iterations,
output_dir=output_dir,
llm_config=llm_config,
runtime_container_image=runtime_container_image,
prompt_template=prompt_template,
issue_type=resolve_request.issue_type,
repo_instruction=None,
issue_number=resolve_request.issue_number,
comment_id=resolve_request.comment_id,
reset_logger=True,
)

# Read output.jsonl file
output_file = os.path.join(output_dir, 'output.jsonl')
if os.path.exists(output_file):
with open(output_file, 'r') as f:
return {'status': 'success', 'output': f.read()}
else:
return {'status': 'error', 'message': 'No output file generated'}

except Exception as e:
return {'status': 'error', 'message': str(e)}
finally:
# Cleanup temp directory
if os.path.exists(output_dir):
import shutil

shutil.rmtree(output_dir)


@app.post('/api/resolver/send-pr')
async def send_pull_request(
request: Request, pr_request: SendPullRequestRequest
) -> dict[str, str | dict[str, Any]]:
"""Create a pull request or branch for resolved issue.

This endpoint creates either:
- A draft PR
- A ready PR
- Just a branch

With the changes made by the resolver.

Args:
request: The incoming request object
pr_request: The PR creation request parameters

Returns:
A dictionary containing the PR/branch creation results with keys:
- status: 'success' or 'error'
- result: PR creation result (on success)
- message: Error message (on error)
"""
try:
# Load the resolver output for this issue
output_dir = os.path.join(tempfile.gettempdir(), 'openhands_resolver')
resolver_output = load_single_resolver_output(
output_dir, pr_request.issue_number
)
if not resolver_output:
raise ValueError(
f'No resolver output found for issue {pr_request.issue_number}'
)

# Get LLM config from current session
llm_config = config.get_llm_config()

# Get GitHub token from environment
github_token = os.environ.get('GITHUB_TOKEN')
if not github_token:
raise ValueError('GITHUB_TOKEN environment variable not set')

# Get GitHub username from environment
github_username = os.environ.get('GITHUB_USERNAME')

# Process the issue
result = await process_single_issue(
output_dir=output_dir,
resolver_output=resolver_output,
github_token=github_token,
github_username=github_username,
pr_type=pr_request.pr_type,
llm_config=llm_config,
fork_owner=pr_request.fork_owner,
send_on_failure=pr_request.send_on_failure,
)
return {'status': 'success', 'result': result}
except Exception as e:
return {'status': 'error', 'message': str(e)}


FILES_TO_IGNORE = [
'.git/',
'.DS_Store',
Expand Down
129 changes: 127 additions & 2 deletions tests/unit/test_listen.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import os
import tempfile
from unittest.mock import patch

from openhands.core.config import AppConfig
import pytest
from fastapi.testclient import TestClient

from openhands.core.config import AppConfig, LLMConfig, SandboxConfig


# Mock the SessionManager to avoid asyncio issues
Expand All @@ -19,7 +24,11 @@ def __init__(self, *args, **kwargs):
with patch('openhands.server.session.SessionManager', MockSessionManager), patch(
'fastapi.staticfiles.StaticFiles', MockStaticFiles
):
from openhands.server.listen import is_extension_allowed, load_file_upload_config
from openhands.server.listen import (
app,
is_extension_allowed,
load_file_upload_config,
)


def test_load_file_upload_config():
Expand Down Expand Up @@ -76,3 +85,119 @@ def test_is_extension_allowed_wildcard():
assert is_extension_allowed('file.pdf')
assert is_extension_allowed('file.doc')
assert is_extension_allowed('file')


@pytest.fixture
def test_client():
"""Create a test client for the FastAPI app."""
return TestClient(app)


@pytest.fixture
def mock_config():
"""Create a mock config for testing."""
config = AppConfig(
sandbox=SandboxConfig(runtime_container_image='test-image'),
llms={'test': LLMConfig(model='test-model', api_key='test-key')},
)
return config


@pytest.fixture
def mock_resolve_issue():
"""Create a mock for resolve_github_issue."""
with patch('openhands.server.listen.resolve_github_issue') as mock:
mock.return_value = None
yield mock


@pytest.fixture
def mock_send_pr():
"""Create a mock for send_github_pr."""
with patch('openhands.server.listen.send_github_pr') as mock:
mock.return_value = {'pr_number': 123}
yield mock


def test_resolve_issue_endpoint(test_client, mock_config, mock_resolve_issue):
"""Test the resolve issue endpoint."""
with patch('openhands.server.listen.config', mock_config):
# Test successful resolution
request_data = {
'owner': 'test-owner',
'repo': 'test-repo',
'token': 'test-token',
'username': 'test-user',
'max_iterations': 50,
'issue_type': 'issue',
'issue_number': 123,
'comment_id': None,
}

# Create a temp file with test output
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl') as tmp:
tmp.write('{"test": "data"}\\n')
tmp.flush()

# Mock tempfile.mkdtemp to return our temp dir
with patch('tempfile.mkdtemp', return_value=os.path.dirname(tmp.name)):
response = test_client.post(
'/api/resolver/resolve-issue', json=request_data
)

assert response.status_code == 200
assert response.json()['status'] == 'success'

# Verify mock was called correctly
mock_resolve_issue.assert_called_once()
call_args = mock_resolve_issue.call_args[1]
assert call_args['owner'] == request_data['owner']
assert call_args['repo'] == request_data['repo']
assert call_args['issue_number'] == request_data['issue_number']

# Test error handling
mock_resolve_issue.side_effect = Exception('Test error')
response = test_client.post('/api/resolver/resolve-issue', json=request_data)
assert response.status_code == 200
assert response.json()['status'] == 'error'
assert response.json()['message'] == 'Test error'

# Test missing output file
mock_resolve_issue.side_effect = None
with tempfile.TemporaryDirectory() as tmp_dir:
with patch('tempfile.mkdtemp', return_value=tmp_dir):
response = test_client.post(
'/api/resolver/resolve-issue', json=request_data
)
assert response.status_code == 200
assert response.json()['status'] == 'error'
assert response.json()['message'] == 'No output file generated'


def test_send_pull_request_endpoint(test_client, mock_send_pr):
"""Test the send pull request endpoint."""
request_data = {
'issue_number': 123,
'pr_type': 'draft',
'fork_owner': None,
'send_on_failure': False,
}

# Test successful PR creation
response = test_client.post('/api/resolver/send-pr', json=request_data)

assert response.status_code == 200
assert response.json()['status'] == 'success'
assert response.json()['result']['pr_number'] == 123

# Verify mock was called correctly
mock_send_pr.assert_called_once_with(
issue_number=123, pr_type='draft', fork_owner=None, send_on_failure=False
)

# Test error handling
mock_send_pr.side_effect = Exception('PR creation failed')
response = test_client.post('/api/resolver/send-pr', json=request_data)
assert response.status_code == 200
assert response.json()['status'] == 'error'
assert response.json()['message'] == 'PR creation failed'
Loading