mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
fix/git-ap
...
v1-github-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
373a94a839 | ||
|
|
e50bb44441 | ||
|
|
894922e335 | ||
|
|
4370e9534f | ||
|
|
b4bdb887be | ||
|
|
3c5972876b |
0
enterprise/tests/integration/__init__.py
Normal file
0
enterprise/tests/integration/__init__.py
Normal file
358
enterprise/tests/integration/v1_github_resolver/conftest.py
Normal file
358
enterprise/tests/integration/v1_github_resolver/conftest.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
Fixtures for V1 GitHub Resolver integration tests.
|
||||
|
||||
These tests run actual conversations with the ProcessSandboxService,
|
||||
using TestLLM to replay pre-recorded trajectories.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Set environment before importing app modules
|
||||
os.environ.setdefault('RUNTIME', 'process')
|
||||
os.environ.setdefault('ENABLE_V1_GITHUB_RESOLVER', 'true')
|
||||
os.environ.setdefault('GITHUB_APP_CLIENT_ID', 'test-app-id')
|
||||
os.environ.setdefault('GITHUB_APP_CLIENT_SECRET', 'test-app-secret')
|
||||
os.environ.setdefault('GITHUB_APP_PRIVATE_KEY', 'test-private-key')
|
||||
os.environ.setdefault('GITHUB_APP_WEBHOOK_SECRET', 'test-webhook-secret')
|
||||
os.environ.setdefault('GITHUB_WEBHOOKS_ENABLED', '1')
|
||||
os.environ.setdefault('HOST', 'localhost')
|
||||
os.environ.setdefault('KEYCLOAK_URL', 'http://localhost:8080')
|
||||
os.environ.setdefault('KEYCLOAK_REALM', 'test-realm')
|
||||
os.environ.setdefault('KEYCLOAK_CLIENT_ID', 'test-client')
|
||||
os.environ.setdefault('KEYCLOAK_CLIENT_SECRET', 'test-secret')
|
||||
|
||||
# Set the templates directory to the absolute path
|
||||
_repo_root = Path(__file__).parent.parent.parent.parent.parent
|
||||
_templates_dir = _repo_root / 'openhands' / 'integrations' / 'templates' / 'resolver'
|
||||
os.environ.setdefault('OPENHANDS_RESOLVER_TEMPLATES_DIR', str(_templates_dir) + '/')
|
||||
|
||||
# Import storage models for database setup
|
||||
# Note: Import ALL models to ensure tables are created
|
||||
# NOTE: Imports must come after environment setup, hence noqa: E402
|
||||
from server.constants import ORG_SETTINGS_VERSION # noqa: E402
|
||||
from storage.auth_tokens import AuthTokens # noqa: E402
|
||||
from storage.base import Base # noqa: E402
|
||||
from storage.billing_session import BillingSession # noqa: E402, F401
|
||||
from storage.conversation_work import ConversationWork # noqa: E402, F401
|
||||
from storage.device_code import DeviceCode # noqa: E402, F401
|
||||
from storage.feedback import Feedback # noqa: E402, F401
|
||||
from storage.github_app_installation import GithubAppInstallation # noqa: E402
|
||||
from storage.org import Org # noqa: E402
|
||||
from storage.org_invitation import OrgInvitation # noqa: E402, F401
|
||||
from storage.org_member import OrgMember # noqa: E402
|
||||
from storage.role import Role # noqa: E402
|
||||
from storage.stored_conversation_metadata import ( # noqa: E402
|
||||
StoredConversationMetadata, # noqa: F401
|
||||
)
|
||||
from storage.stored_conversation_metadata_saas import ( # noqa: E402
|
||||
StoredConversationMetadataSaas, # noqa: F401
|
||||
)
|
||||
from storage.stored_offline_token import StoredOfflineToken # noqa: E402
|
||||
from storage.stripe_customer import StripeCustomer # noqa: E402, F401
|
||||
from storage.user import User # noqa: E402
|
||||
|
||||
# Test constants
|
||||
TEST_USER_UUID = UUID('11111111-1111-1111-1111-111111111111')
|
||||
TEST_ORG_UUID = UUID('22222222-2222-2222-2222-222222222222')
|
||||
TEST_KEYCLOAK_USER_ID = 'test-keycloak-user-id'
|
||||
TEST_GITHUB_USER_ID = 12345
|
||||
TEST_GITHUB_USERNAME = 'test-github-user'
|
||||
TEST_WEBHOOK_SECRET = 'test-webhook-secret'
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def test_env():
|
||||
"""Environment variables for testing."""
|
||||
return {
|
||||
'RUNTIME': 'process',
|
||||
'ENABLE_V1_GITHUB_RESOLVER': 'true',
|
||||
'GITHUB_APP_CLIENT_ID': 'test-app-id',
|
||||
'GITHUB_APP_WEBHOOK_SECRET': TEST_WEBHOOK_SECRET,
|
||||
'GITHUB_WEBHOOKS_ENABLED': '1',
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine():
|
||||
"""Create an in-memory SQLite database engine for enterprise tables."""
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_maker(engine):
|
||||
"""Create a session maker bound to the test engine."""
|
||||
return sessionmaker(bind=engine)
|
||||
|
||||
|
||||
TEST_INSTALLATION_ID = 123456
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_db(session_maker):
|
||||
"""Seed the database with test user data."""
|
||||
now = datetime.now(tz=None) # Use naive datetime for SQLite compatibility
|
||||
|
||||
with session_maker() as session:
|
||||
# Create role
|
||||
session.add(Role(id=1, name='admin', rank=1))
|
||||
|
||||
# Create org with V1 enabled
|
||||
session.add(
|
||||
Org(
|
||||
id=TEST_ORG_UUID,
|
||||
name='test-org',
|
||||
org_version=ORG_SETTINGS_VERSION,
|
||||
enable_default_condenser=True,
|
||||
enable_proactive_conversation_starters=False,
|
||||
v1_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Create user
|
||||
session.add(
|
||||
User(
|
||||
id=TEST_USER_UUID,
|
||||
current_org_id=TEST_ORG_UUID,
|
||||
user_consents_to_analytics=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Create org member with LLM API key
|
||||
session.add(
|
||||
OrgMember(
|
||||
org_id=TEST_ORG_UUID,
|
||||
user_id=TEST_USER_UUID,
|
||||
role_id=1,
|
||||
llm_api_key='test-llm-api-key',
|
||||
status='active',
|
||||
)
|
||||
)
|
||||
|
||||
# Create offline token for Keycloak user
|
||||
session.add(
|
||||
StoredOfflineToken(
|
||||
user_id=TEST_KEYCLOAK_USER_ID,
|
||||
offline_token='test-offline-token',
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
# Create auth tokens linking Keycloak user to GitHub
|
||||
future_time = int((now + timedelta(hours=1)).timestamp())
|
||||
session.add(
|
||||
AuthTokens(
|
||||
keycloak_user_id=TEST_KEYCLOAK_USER_ID,
|
||||
identity_provider='github',
|
||||
access_token='test-github-access-token',
|
||||
refresh_token='test-github-refresh-token',
|
||||
access_token_expires_at=future_time,
|
||||
refresh_token_expires_at=future_time + 86400,
|
||||
)
|
||||
)
|
||||
|
||||
# Create GitHub app installation
|
||||
session.add(
|
||||
GithubAppInstallation(
|
||||
installation_id=str(TEST_INSTALLATION_ID),
|
||||
encrypted_token='test-encrypted-token',
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
|
||||
return session_maker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_session_maker(seeded_db):
|
||||
"""Patch all imports of session_maker to use our test database.
|
||||
|
||||
This is necessary because the enterprise code imports session_maker
|
||||
at module level from storage.database.
|
||||
"""
|
||||
patches = [
|
||||
patch('storage.database.session_maker', seeded_db),
|
||||
patch('integrations.github.github_view.session_maker', seeded_db),
|
||||
patch('integrations.github.github_solvability.session_maker', seeded_db),
|
||||
patch('server.auth.token_manager.session_maker', seeded_db),
|
||||
patch('server.auth.saas_user_auth.session_maker', seeded_db),
|
||||
patch('server.auth.domain_blocker.session_maker', seeded_db),
|
||||
]
|
||||
|
||||
for p in patches:
|
||||
p.start()
|
||||
|
||||
yield seeded_db
|
||||
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_keycloak():
|
||||
"""Mock Keycloak admin API to return our test user."""
|
||||
|
||||
async def mock_get_users(query: dict) -> list[dict]:
|
||||
"""Mock user lookup by GitHub ID."""
|
||||
q = query.get('q', '')
|
||||
if f'github_id:{TEST_GITHUB_USER_ID}' in q:
|
||||
return [{'id': TEST_KEYCLOAK_USER_ID, 'username': TEST_GITHUB_USERNAME}]
|
||||
return []
|
||||
|
||||
mock_admin = MagicMock()
|
||||
mock_admin.a_get_users = AsyncMock(side_effect=mock_get_users)
|
||||
|
||||
with patch('server.auth.token_manager.get_keycloak_admin', return_value=mock_admin):
|
||||
yield mock_admin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_github_api():
|
||||
"""Mock PyGithub API to capture posted comments (legacy - prefer mock_github_service)."""
|
||||
captured_comments = []
|
||||
captured_reactions = []
|
||||
|
||||
mock_issue = MagicMock()
|
||||
mock_issue.create_comment = MagicMock(
|
||||
side_effect=lambda body: captured_comments.append(body)
|
||||
)
|
||||
mock_issue.create_reaction = MagicMock(
|
||||
side_effect=lambda reaction: captured_reactions.append(reaction)
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_issue = MagicMock(return_value=mock_issue)
|
||||
|
||||
mock_github = MagicMock()
|
||||
mock_github.get_repo = MagicMock(return_value=mock_repo)
|
||||
|
||||
with patch('github.Github', return_value=mock_github):
|
||||
yield {
|
||||
'github': mock_github,
|
||||
'repo': mock_repo,
|
||||
'issue': mock_issue,
|
||||
'captured_comments': captured_comments,
|
||||
'captured_reactions': captured_reactions,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_github_service():
|
||||
"""
|
||||
Real HTTP mock GitHub service.
|
||||
|
||||
This fixture starts an in-process HTTP server that implements the GitHub API.
|
||||
PyGithub clients are patched to use this server instead of api.github.com.
|
||||
|
||||
Usage:
|
||||
def test_something(mock_github_service):
|
||||
# Configure the service
|
||||
mock_github_service.configure_repo('owner/repo')
|
||||
mock_github_service.configure_issue('owner/repo', 1)
|
||||
|
||||
# ... run your test code ...
|
||||
|
||||
# Verify
|
||||
mock_github_service.assert_comment_sent("I'm on it")
|
||||
mock_github_service.assert_reaction_added("eyes")
|
||||
"""
|
||||
from github import Github
|
||||
|
||||
from .mocks import MockGitHubService
|
||||
|
||||
# Create and start the mock service
|
||||
service = MockGitHubService()
|
||||
service.start()
|
||||
|
||||
# Patch Github to use our mock server
|
||||
original_init = Github.__init__
|
||||
|
||||
def patched_init(self, *args, **kwargs):
|
||||
kwargs['base_url'] = service.base_url
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
with patch.object(Github, '__init__', patched_init):
|
||||
yield service
|
||||
|
||||
service.stop()
|
||||
|
||||
|
||||
def create_webhook_signature(payload: bytes, secret: str) -> str:
|
||||
"""Create a GitHub webhook signature."""
|
||||
signature = hmac.new(
|
||||
secret.encode('utf-8'), msg=payload, digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
return f'sha256={signature}'
|
||||
|
||||
|
||||
def create_issue_comment_payload(
|
||||
issue_number: int = 1,
|
||||
comment_body: str = '@openhands please fix this',
|
||||
repo_name: str = 'test-owner/test-repo',
|
||||
sender_id: int = TEST_GITHUB_USER_ID,
|
||||
sender_login: str = TEST_GITHUB_USERNAME,
|
||||
installation_id: int = 123456,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a GitHub issue comment webhook payload."""
|
||||
owner, repo = repo_name.split('/')
|
||||
return {
|
||||
'action': 'created',
|
||||
'issue': {
|
||||
'number': issue_number,
|
||||
'title': 'Test Issue',
|
||||
'body': 'This is a test issue',
|
||||
'html_url': f'https://github.com/{repo_name}/issues/{issue_number}',
|
||||
'user': {'login': sender_login, 'id': sender_id},
|
||||
},
|
||||
'comment': {
|
||||
'id': 12345,
|
||||
'body': comment_body,
|
||||
'user': {'login': sender_login, 'id': sender_id},
|
||||
'html_url': f'https://github.com/{repo_name}/issues/{issue_number}#issuecomment-12345',
|
||||
},
|
||||
'repository': {
|
||||
'id': 12345678,
|
||||
'name': repo,
|
||||
'full_name': repo_name,
|
||||
'private': False,
|
||||
'html_url': f'https://github.com/{repo_name}',
|
||||
'owner': {
|
||||
'login': owner,
|
||||
'id': 99999,
|
||||
},
|
||||
},
|
||||
'sender': {'login': sender_login, 'id': sender_id},
|
||||
'installation': {'id': installation_id},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def issue_comment_payload():
|
||||
"""Create a standard issue comment payload."""
|
||||
return create_issue_comment_payload()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trajectory_path():
|
||||
"""Path to trajectory files."""
|
||||
return Path(__file__).parent / 'fixtures' / 'trajectories'
|
||||
|
||||
|
||||
# Note: TestLLM injection will be handled separately as it requires
|
||||
# the SDK to be installed and configured properly
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "simple_finish",
|
||||
"description": "Agent immediately finishes with a simple message",
|
||||
"responses": [
|
||||
{
|
||||
"text": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_finish_1",
|
||||
"name": "finish",
|
||||
"arguments": "{\"message\": \"I have analyzed the issue and completed the task.\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Mocks for V1 GitHub Resolver integration tests."""
|
||||
|
||||
from .github_service import MockGitHubService
|
||||
from .test_llm import TestLLM
|
||||
|
||||
__all__ = ['MockGitHubService', 'TestLLM']
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
Mock GitHub API Service for integration tests.
|
||||
|
||||
This module provides a real HTTP server that implements the GitHub API endpoints
|
||||
used by the enterprise code. It tracks all API calls and state, allowing tests
|
||||
to verify behavior without complex mocking.
|
||||
|
||||
Usage:
|
||||
service = MockGitHubService()
|
||||
service.start()
|
||||
|
||||
# Run test code that uses PyGithub...
|
||||
|
||||
# Verify
|
||||
service.assert_comment_sent("I'm on it")
|
||||
service.assert_reaction_added("eyes")
|
||||
|
||||
service.stop()
|
||||
"""
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockGitHubService:
|
||||
"""In-process mock GitHub API server that tracks state."""
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 0 # 0 = auto-assign available port
|
||||
|
||||
# Tracked state
|
||||
comments: list = field(default_factory=list)
|
||||
reactions: list = field(default_factory=list)
|
||||
api_calls: list = field(default_factory=list)
|
||||
|
||||
# Configurable data
|
||||
repos: dict = field(default_factory=dict)
|
||||
issues: dict = field(default_factory=dict)
|
||||
pull_requests: dict = field(default_factory=dict)
|
||||
issue_comments: dict = field(default_factory=dict)
|
||||
|
||||
# Internal
|
||||
_app: FastAPI | None = None
|
||||
_server: uvicorn.Server | None = None
|
||||
_thread: threading.Thread | None = None
|
||||
_ready: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.port == 0:
|
||||
self.port = self._find_free_port()
|
||||
|
||||
def _find_free_port(self) -> int:
|
||||
"""Find an available port."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""URL to use as GitHub API base."""
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
def configure_repo(
|
||||
self,
|
||||
full_name: str,
|
||||
repo_id: int = 1,
|
||||
private: bool = False,
|
||||
) -> "MockGitHubService":
|
||||
"""Configure a repository."""
|
||||
owner, name = full_name.split("/")
|
||||
self.repos[full_name] = {
|
||||
"id": repo_id,
|
||||
"name": name,
|
||||
"full_name": full_name,
|
||||
"private": private,
|
||||
"owner": {"login": owner, "id": 1},
|
||||
}
|
||||
return self
|
||||
|
||||
def configure_issue(
|
||||
self,
|
||||
full_name: str,
|
||||
number: int,
|
||||
title: str = "Test Issue",
|
||||
body: str = "Test body",
|
||||
user_login: str = "testuser",
|
||||
) -> "MockGitHubService":
|
||||
"""Configure an issue."""
|
||||
key = f"{full_name}/{number}"
|
||||
self.issues[key] = {
|
||||
"id": number,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"user": {"login": user_login, "id": 1},
|
||||
}
|
||||
return self
|
||||
|
||||
def configure_pull_request(
|
||||
self,
|
||||
full_name: str,
|
||||
number: int,
|
||||
title: str = "Test PR",
|
||||
body: str = "Test body",
|
||||
user_login: str = "testuser",
|
||||
) -> "MockGitHubService":
|
||||
"""Configure a pull request."""
|
||||
key = f"{full_name}/{number}"
|
||||
self.pull_requests[key] = {
|
||||
"id": number,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"user": {"login": user_login, "id": 1},
|
||||
}
|
||||
return self
|
||||
|
||||
def configure_comment(
|
||||
self,
|
||||
full_name: str,
|
||||
comment_id: int,
|
||||
body: str = "Test comment",
|
||||
user_login: str = "testuser",
|
||||
) -> "MockGitHubService":
|
||||
"""Configure an issue comment."""
|
||||
key = f"{full_name}/{comment_id}"
|
||||
self.issue_comments[key] = {
|
||||
"id": comment_id,
|
||||
"body": body,
|
||||
"user": {"login": user_login, "id": 1},
|
||||
}
|
||||
return self
|
||||
|
||||
def start(self) -> "MockGitHubService":
|
||||
"""Start the mock server in a background thread."""
|
||||
self._create_app()
|
||||
|
||||
config = uvicorn.Config(
|
||||
self._app,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_level="error",
|
||||
access_log=False,
|
||||
)
|
||||
self._server = uvicorn.Server(config)
|
||||
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
# Wait for server to be ready by polling
|
||||
import requests
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 10:
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/_test/state", timeout=0.5)
|
||||
if resp.status_code == 200:
|
||||
self._ready.set()
|
||||
break
|
||||
except requests.exceptions.RequestException:
|
||||
time.sleep(0.1)
|
||||
|
||||
if not self._ready.is_set():
|
||||
raise RuntimeError("Mock GitHub server failed to start")
|
||||
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
"""Stop the mock server."""
|
||||
if self._server:
|
||||
self._server.should_exit = True
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def reset(self):
|
||||
"""Reset tracked state (but keep configuration)."""
|
||||
self.comments.clear()
|
||||
self.reactions.clear()
|
||||
self.api_calls.clear()
|
||||
|
||||
def _run(self):
|
||||
"""Run the server (called in background thread)."""
|
||||
if self._server:
|
||||
self._server.run()
|
||||
|
||||
def _create_app(self):
|
||||
"""Create the FastAPI app with GitHub API endpoints."""
|
||||
app = FastAPI()
|
||||
service = self # Capture reference for closures
|
||||
|
||||
# Middleware to log all API calls
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
body = None
|
||||
if request.method in ("POST", "PUT", "PATCH"):
|
||||
body = await request.body()
|
||||
try:
|
||||
body = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
body = body.decode() if isinstance(body, bytes) else body
|
||||
|
||||
service.api_calls.append(
|
||||
{
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"body": body,
|
||||
}
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Repository endpoints
|
||||
@app.get("/repos/{owner}/{repo}")
|
||||
async def get_repo(owner: str, repo: str, request: Request):
|
||||
full_name = f"{owner}/{repo}"
|
||||
if full_name in service.repos:
|
||||
data = service.repos[full_name].copy()
|
||||
data["url"] = str(request.url)
|
||||
return JSONResponse(data)
|
||||
# Return default repo
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": 1,
|
||||
"name": repo,
|
||||
"full_name": full_name,
|
||||
"url": str(request.url),
|
||||
"owner": {"login": owner, "id": 1},
|
||||
}
|
||||
)
|
||||
|
||||
# Issue endpoints
|
||||
@app.get("/repos/{owner}/{repo}/issues/{issue_number}")
|
||||
async def get_issue(
|
||||
owner: str, repo: str, issue_number: int, request: Request
|
||||
):
|
||||
key = f"{owner}/{repo}/{issue_number}"
|
||||
if key in service.issues:
|
||||
data = service.issues[key].copy()
|
||||
data["url"] = str(request.url)
|
||||
return JSONResponse(data)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": issue_number,
|
||||
"number": issue_number,
|
||||
"title": "Test Issue",
|
||||
"body": "Test body",
|
||||
"url": str(request.url),
|
||||
"user": {"login": "testuser", "id": 1},
|
||||
}
|
||||
)
|
||||
|
||||
# Pull request endpoints
|
||||
@app.get("/repos/{owner}/{repo}/pulls/{pr_number}")
|
||||
async def get_pull(owner: str, repo: str, pr_number: int, request: Request):
|
||||
key = f"{owner}/{repo}/{pr_number}"
|
||||
if key in service.pull_requests:
|
||||
data = service.pull_requests[key].copy()
|
||||
data["url"] = str(request.url)
|
||||
return JSONResponse(data)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": pr_number,
|
||||
"number": pr_number,
|
||||
"title": "Test PR",
|
||||
"body": "Test body",
|
||||
"url": str(request.url),
|
||||
"user": {"login": "testuser", "id": 1},
|
||||
}
|
||||
)
|
||||
|
||||
# Comment endpoints
|
||||
@app.get("/repos/{owner}/{repo}/issues/comments/{comment_id}")
|
||||
async def get_issue_comment(
|
||||
owner: str, repo: str, comment_id: int, request: Request
|
||||
):
|
||||
key = f"{owner}/{repo}/{comment_id}"
|
||||
if key in service.issue_comments:
|
||||
data = service.issue_comments[key].copy()
|
||||
data["url"] = str(request.url)
|
||||
return JSONResponse(data)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": comment_id,
|
||||
"body": "Test comment",
|
||||
"url": str(request.url),
|
||||
"user": {"login": "testuser", "id": 1},
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/repos/{owner}/{repo}/issues/{issue_number}/comments")
|
||||
async def list_issue_comments(
|
||||
owner: str, repo: str, issue_number: int, request: Request
|
||||
):
|
||||
# Return empty list by default
|
||||
return JSONResponse([])
|
||||
|
||||
@app.post("/repos/{owner}/{repo}/issues/{issue_number}/comments")
|
||||
async def create_issue_comment(
|
||||
owner: str, repo: str, issue_number: int, request: Request
|
||||
):
|
||||
body = await request.json()
|
||||
comment_data = {
|
||||
"repo": f"{owner}/{repo}",
|
||||
"issue_number": issue_number,
|
||||
"body": body.get("body", ""),
|
||||
}
|
||||
service.comments.append(comment_data)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": len(service.comments),
|
||||
"body": body.get("body", ""),
|
||||
"url": str(request.url),
|
||||
"user": {"login": "bot", "id": 1},
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
# Reaction endpoints
|
||||
@app.post("/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions")
|
||||
async def create_comment_reaction(
|
||||
owner: str, repo: str, comment_id: int, request: Request
|
||||
):
|
||||
body = await request.json()
|
||||
reaction_data = {
|
||||
"repo": f"{owner}/{repo}",
|
||||
"comment_id": comment_id,
|
||||
"content": body.get("content", ""),
|
||||
}
|
||||
service.reactions.append(reaction_data)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": len(service.reactions),
|
||||
"content": body.get("content", ""),
|
||||
"url": str(request.url),
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
@app.post("/repos/{owner}/{repo}/issues/{issue_number}/reactions")
|
||||
async def create_issue_reaction(
|
||||
owner: str, repo: str, issue_number: int, request: Request
|
||||
):
|
||||
body = await request.json()
|
||||
reaction_data = {
|
||||
"repo": f"{owner}/{repo}",
|
||||
"issue_number": issue_number,
|
||||
"content": body.get("content", ""),
|
||||
}
|
||||
service.reactions.append(reaction_data)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": len(service.reactions),
|
||||
"content": body.get("content", ""),
|
||||
"url": str(request.url),
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
# PR review comment reply
|
||||
@app.post(
|
||||
"/repos/{owner}/{repo}/pulls/{pr_number}/comments/{comment_id}/replies"
|
||||
)
|
||||
async def create_review_comment_reply(
|
||||
owner: str, repo: str, pr_number: int, comment_id: int, request: Request
|
||||
):
|
||||
body = await request.json()
|
||||
comment_data = {
|
||||
"repo": f"{owner}/{repo}",
|
||||
"pr_number": pr_number,
|
||||
"reply_to_comment_id": comment_id,
|
||||
"body": body.get("body", ""),
|
||||
}
|
||||
service.comments.append(comment_data)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": len(service.comments),
|
||||
"body": body.get("body", ""),
|
||||
"url": str(request.url),
|
||||
"user": {"login": "bot", "id": 1},
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
# PR comment (not review comment)
|
||||
@app.post("/repos/{owner}/{repo}/issues/{pr_number}/comments")
|
||||
async def create_pr_comment(
|
||||
owner: str, repo: str, pr_number: int, request: Request
|
||||
):
|
||||
# PRs can also receive issue comments
|
||||
return await create_issue_comment(owner, repo, pr_number, request)
|
||||
|
||||
# Test endpoint to query state
|
||||
@app.get("/_test/state")
|
||||
async def get_state():
|
||||
return JSONResponse(
|
||||
{
|
||||
"comments": service.comments,
|
||||
"reactions": service.reactions,
|
||||
"api_calls": service.api_calls,
|
||||
}
|
||||
)
|
||||
|
||||
@app.post("/_test/reset")
|
||||
async def reset_state():
|
||||
service.reset()
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
self._app = app
|
||||
|
||||
# Assertion helpers
|
||||
def get_comments(self) -> list[dict[str, Any]]:
|
||||
"""Get all comments that were created."""
|
||||
return self.comments.copy()
|
||||
|
||||
def get_reactions(self) -> list[dict[str, Any]]:
|
||||
"""Get all reactions that were created."""
|
||||
return self.reactions.copy()
|
||||
|
||||
def get_api_calls(self) -> list[dict[str, Any]]:
|
||||
"""Get all API calls that were made."""
|
||||
return self.api_calls.copy()
|
||||
|
||||
def assert_comment_sent(self, body_contains: str) -> dict[str, Any]:
|
||||
"""Assert that a comment containing the given text was sent."""
|
||||
for comment in self.comments:
|
||||
if body_contains in comment.get("body", ""):
|
||||
return comment
|
||||
raise AssertionError(
|
||||
f"No comment containing '{body_contains}' was sent.\n"
|
||||
f"Comments sent: {[c.get('body', '')[:50] for c in self.comments]}"
|
||||
)
|
||||
|
||||
def assert_reaction_added(self, content: str) -> dict[str, Any]:
|
||||
"""Assert that a reaction with the given content was added."""
|
||||
for reaction in self.reactions:
|
||||
if reaction.get("content") == content:
|
||||
return reaction
|
||||
raise AssertionError(
|
||||
f"No '{content}' reaction was added.\n"
|
||||
f"Reactions: {[r.get('content') for r in self.reactions]}"
|
||||
)
|
||||
|
||||
def assert_no_comments(self):
|
||||
"""Assert that no comments were sent."""
|
||||
if self.comments:
|
||||
raise AssertionError(
|
||||
f"Expected no comments, but {len(self.comments)} were sent:\n"
|
||||
f"{[c.get('body', '')[:50] for c in self.comments]}"
|
||||
)
|
||||
|
||||
def wait_for_comment(self, body_contains: str, timeout: float = 5.0) -> dict:
|
||||
"""Wait for a comment containing the given text."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
for comment in self.comments:
|
||||
if body_contains in comment.get("body", ""):
|
||||
return comment
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for comment containing '{body_contains}'"
|
||||
)
|
||||
|
||||
def wait_for_reaction(self, content: str, timeout: float = 5.0) -> dict:
|
||||
"""Wait for a reaction with the given content."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
for reaction in self.reactions:
|
||||
if reaction.get("content") == content:
|
||||
return reaction
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError(f"Timed out waiting for '{content}' reaction")
|
||||
@@ -0,0 +1,217 @@
|
||||
"""TestLLM - A mock LLM for testing V1 GitHub Resolver.
|
||||
|
||||
This is a simplified version of the TestLLM from openhands.sdk.testing
|
||||
that returns scripted responses without making real LLM API calls.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from typing import Any, ClassVar, Sequence
|
||||
|
||||
from litellm.types.utils import Choices, ModelResponse
|
||||
from litellm.types.utils import Message as LiteLLMMessage
|
||||
from pydantic import ConfigDict, Field, PrivateAttr
|
||||
|
||||
from openhands.sdk.llm.llm import LLM
|
||||
from openhands.sdk.llm.llm_response import LLMResponse
|
||||
from openhands.sdk.llm.message import Message, TextContent
|
||||
from openhands.sdk.llm.streaming import TokenCallbackType
|
||||
from openhands.sdk.llm.utils.metrics import MetricsSnapshot, TokenUsage
|
||||
from openhands.sdk.tool.tool import ToolDefinition
|
||||
|
||||
__all__ = ['TestLLM', 'TestLLMExhaustedError']
|
||||
|
||||
|
||||
class TestLLMExhaustedError(Exception):
|
||||
"""Raised when TestLLM has no more scripted responses."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestLLM(LLM):
|
||||
"""A mock LLM for testing that returns scripted responses.
|
||||
|
||||
TestLLM is a real LLM subclass that can be used anywhere an LLM is accepted.
|
||||
It returns pre-scripted responses without making any API calls.
|
||||
"""
|
||||
|
||||
# Prevent pytest from collecting this class as a test
|
||||
__test__ = False
|
||||
|
||||
model: str = Field(default='test-model')
|
||||
_scripted_responses: deque[Message | Exception] = PrivateAttr(default_factory=deque)
|
||||
_call_count: int = PrivateAttr(default=0)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(
|
||||
extra='ignore', arbitrary_types_allowed=True
|
||||
)
|
||||
|
||||
def __init__(self, **data: Any) -> None:
|
||||
# Extract scripted_responses before calling super().__init__
|
||||
scripted_responses = data.pop('scripted_responses', [])
|
||||
super().__init__(**data)
|
||||
self._scripted_responses = deque(list(scripted_responses))
|
||||
self._call_count = 0
|
||||
|
||||
@classmethod
|
||||
def from_messages(
|
||||
cls,
|
||||
messages: list[Message | Exception],
|
||||
*,
|
||||
model: str = 'test-model',
|
||||
usage_id: str = 'test-llm',
|
||||
**kwargs: Any,
|
||||
) -> 'TestLLM':
|
||||
"""Create a TestLLM with scripted responses.
|
||||
|
||||
Args:
|
||||
messages: List of Message or Exception objects to return in order.
|
||||
model: Model name (default: "test-model")
|
||||
usage_id: Usage ID for metrics (default: "test-llm")
|
||||
**kwargs: Additional LLM configuration options
|
||||
|
||||
Returns:
|
||||
A TestLLM instance configured with the scripted responses.
|
||||
"""
|
||||
return cls(
|
||||
model=model,
|
||||
usage_id=usage_id,
|
||||
scripted_responses=messages,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def completion(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: Sequence[ToolDefinition] | None = None,
|
||||
_return_metrics: bool = False,
|
||||
add_security_risk_prediction: bool = False,
|
||||
on_token: TokenCallbackType | None = None,
|
||||
**kwargs: Any,
|
||||
) -> LLMResponse:
|
||||
"""Return the next scripted response.
|
||||
|
||||
Args:
|
||||
messages: Input messages (ignored)
|
||||
tools: Available tools (ignored)
|
||||
_return_metrics: Whether to return metrics (ignored)
|
||||
add_security_risk_prediction: Add security risk field (ignored)
|
||||
on_token: Streaming callback (ignored)
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
LLMResponse containing the next scripted message.
|
||||
|
||||
Raises:
|
||||
TestLLMExhaustedError: When no more scripted responses are available.
|
||||
"""
|
||||
if not self._scripted_responses:
|
||||
raise TestLLMExhaustedError(
|
||||
f'TestLLM: no more scripted responses '
|
||||
f'(exhausted after {self._call_count} calls)'
|
||||
)
|
||||
|
||||
item = self._scripted_responses.popleft()
|
||||
self._call_count += 1
|
||||
|
||||
# Raise scripted exceptions
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
|
||||
message = item
|
||||
|
||||
# Create a minimal ModelResponse for raw_response
|
||||
raw_response = self._create_model_response(message)
|
||||
|
||||
return LLMResponse(
|
||||
message=message,
|
||||
metrics=self._zero_metrics(),
|
||||
raw_response=raw_response,
|
||||
)
|
||||
|
||||
def responses(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: Sequence[ToolDefinition] | None = None,
|
||||
include: list[str] | None = None,
|
||||
store: bool | None = None,
|
||||
_return_metrics: bool = False,
|
||||
add_security_risk_prediction: bool = False,
|
||||
on_token: TokenCallbackType | None = None,
|
||||
**kwargs: Any,
|
||||
) -> LLMResponse:
|
||||
"""Return the next scripted response (delegates to completion)."""
|
||||
return self.completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
_return_metrics=_return_metrics,
|
||||
add_security_risk_prediction=add_security_risk_prediction,
|
||||
on_token=on_token,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def uses_responses_api(self) -> bool:
|
||||
"""TestLLM always uses the completion path."""
|
||||
return False
|
||||
|
||||
def _zero_metrics(self) -> MetricsSnapshot:
|
||||
"""Return a zero-cost metrics snapshot."""
|
||||
return MetricsSnapshot(
|
||||
model_name=self.model,
|
||||
accumulated_cost=0.0,
|
||||
max_budget_per_task=None,
|
||||
accumulated_token_usage=TokenUsage(
|
||||
model=self.model,
|
||||
prompt_tokens=0,
|
||||
completion_tokens=0,
|
||||
),
|
||||
)
|
||||
|
||||
def _create_model_response(self, message: Message) -> ModelResponse:
|
||||
"""Create a minimal ModelResponse from a Message."""
|
||||
# Build the LiteLLM message dict
|
||||
litellm_message_dict: dict[str, Any] = {
|
||||
'role': message.role,
|
||||
'content': self._content_to_string(message),
|
||||
}
|
||||
|
||||
# Add tool_calls if present
|
||||
if message.tool_calls:
|
||||
litellm_message_dict['tool_calls'] = [
|
||||
{
|
||||
'id': tc.id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tc.name,
|
||||
'arguments': tc.arguments,
|
||||
},
|
||||
}
|
||||
for tc in message.tool_calls
|
||||
]
|
||||
|
||||
litellm_message = LiteLLMMessage(**litellm_message_dict)
|
||||
|
||||
return ModelResponse(
|
||||
id=f'test-response-{self._call_count}',
|
||||
choices=[Choices(message=litellm_message, index=0, finish_reason='stop')],
|
||||
created=0,
|
||||
model=self.model,
|
||||
object='chat.completion',
|
||||
)
|
||||
|
||||
def _content_to_string(self, message: Message) -> str:
|
||||
"""Convert message content to a string."""
|
||||
parts = []
|
||||
for item in message.content:
|
||||
if isinstance(item, TextContent):
|
||||
parts.append(item.text)
|
||||
return '\n'.join(parts)
|
||||
|
||||
@property
|
||||
def remaining_responses(self) -> int:
|
||||
"""Return the number of remaining scripted responses."""
|
||||
return len(self._scripted_responses)
|
||||
|
||||
@property
|
||||
def call_count(self) -> int:
|
||||
"""Return the number of calls made to this TestLLM."""
|
||||
return self._call_count
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Integration test for V1 GitHub Resolver webhook flow.
|
||||
|
||||
This test verifies:
|
||||
1. Webhook triggers agent server creation
|
||||
2. "I'm on it" message is sent to GitHub
|
||||
3. Eyes reaction is added to acknowledge the request
|
||||
|
||||
Uses MockGitHubService for real HTTP calls to a mock GitHub API.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import (
|
||||
TEST_GITHUB_USER_ID,
|
||||
TEST_GITHUB_USERNAME,
|
||||
create_issue_comment_payload,
|
||||
)
|
||||
|
||||
|
||||
class TestV1GitHubResolverE2E:
|
||||
"""E2E test for V1 GitHub Resolver with MockGitHubService."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_flow_with_mock_github_service(
|
||||
self, patched_session_maker, mock_keycloak, mock_github_service
|
||||
):
|
||||
"""
|
||||
E2E test: Webhook → Agent Server → Real HTTP calls to MockGitHubService.
|
||||
|
||||
This test:
|
||||
1. Receives a GitHub webhook payload
|
||||
2. Routes to V1 path (v1_enabled=True)
|
||||
3. Starts agent server via start_app_conversation
|
||||
4. Makes REAL HTTP calls to MockGitHubService
|
||||
5. Verifies "I'm on it" and eyes reaction via service state
|
||||
"""
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartTask,
|
||||
AppConversationStartTaskStatus,
|
||||
)
|
||||
|
||||
# Configure the mock GitHub service
|
||||
mock_github_service.configure_repo('test-owner/test-repo')
|
||||
mock_github_service.configure_issue(
|
||||
'test-owner/test-repo',
|
||||
number=1,
|
||||
title='Test Issue',
|
||||
body='This is a test issue',
|
||||
)
|
||||
mock_github_service.configure_comment(
|
||||
'test-owner/test-repo',
|
||||
comment_id=12345,
|
||||
body='@openhands please fix this bug',
|
||||
)
|
||||
|
||||
# Create webhook payload
|
||||
payload = create_issue_comment_payload(
|
||||
comment_body='@openhands please fix this bug',
|
||||
sender_id=TEST_GITHUB_USER_ID,
|
||||
sender_login=TEST_GITHUB_USERNAME,
|
||||
)
|
||||
|
||||
# Track agent server start
|
||||
agent_started = asyncio.Event()
|
||||
captured_request = None
|
||||
|
||||
# Mock start_app_conversation to simulate agent server
|
||||
async def mock_start_app_conversation(request):
|
||||
from uuid import uuid4
|
||||
|
||||
nonlocal captured_request
|
||||
captured_request = request
|
||||
agent_started.set()
|
||||
|
||||
task_id = uuid4()
|
||||
conv_id = uuid4()
|
||||
|
||||
yield AppConversationStartTask(
|
||||
id=task_id,
|
||||
created_by_user_id='test-user',
|
||||
status=AppConversationStartTaskStatus.WORKING,
|
||||
request=request,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
yield AppConversationStartTask(
|
||||
id=task_id,
|
||||
created_by_user_id='test-user',
|
||||
status=AppConversationStartTaskStatus.READY,
|
||||
app_conversation_id=conv_id,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# Mock GithubServiceImpl (for fetching issue details)
|
||||
mock_github_service_impl = MagicMock()
|
||||
mock_github_service_impl.get_issue_or_pr_comments = AsyncMock(return_value=[])
|
||||
mock_github_service_impl.get_issue_or_pr_title_and_body = AsyncMock(
|
||||
return_value=('Test Issue', 'This is a test issue body')
|
||||
)
|
||||
mock_github_service_impl.get_review_thread_comments = AsyncMock(return_value=[])
|
||||
|
||||
# Mock app conversation service
|
||||
mock_app_service = MagicMock()
|
||||
mock_app_service.start_app_conversation = mock_start_app_conversation
|
||||
|
||||
with patch(
|
||||
'integrations.github.github_view.get_user_v1_enabled_setting',
|
||||
return_value=True,
|
||||
), patch(
|
||||
'integrations.github.github_view.get_app_conversation_service'
|
||||
) as mock_get_service, patch(
|
||||
'github.GithubIntegration'
|
||||
) as mock_integration, patch(
|
||||
'integrations.github.github_solvability.summarize_issue_solvability',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
), patch(
|
||||
'server.auth.token_manager.TokenManager.get_idp_token_from_idp_user_id',
|
||||
new_callable=AsyncMock,
|
||||
return_value='mock-token',
|
||||
), patch(
|
||||
'integrations.v1_utils.get_saas_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_saas_auth, patch(
|
||||
'integrations.github.github_view.GithubServiceImpl',
|
||||
return_value=mock_github_service_impl,
|
||||
):
|
||||
# Setup mock service context
|
||||
mock_context = MagicMock()
|
||||
mock_context.__aenter__ = AsyncMock(return_value=mock_app_service)
|
||||
mock_context.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_get_service.return_value = mock_context
|
||||
|
||||
# Setup user auth
|
||||
mock_user_auth = MagicMock()
|
||||
mock_user_auth.get_provider_tokens = AsyncMock(
|
||||
return_value={'github': 'mock-token'}
|
||||
)
|
||||
mock_saas_auth.return_value = mock_user_auth
|
||||
|
||||
# Setup GitHub integration
|
||||
mock_token = MagicMock()
|
||||
mock_token.token = 'test-installation-token'
|
||||
mock_integration.return_value.get_access_token.return_value = mock_token
|
||||
|
||||
# Run the test
|
||||
from integrations.github.github_manager import GithubManager
|
||||
from integrations.models import Message, SourceType
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
token_manager = TokenManager()
|
||||
token_manager.load_org_token = MagicMock(return_value='mock-token')
|
||||
|
||||
data_collector = MagicMock()
|
||||
data_collector.process_payload = MagicMock()
|
||||
data_collector.fetch_issue_details = AsyncMock(
|
||||
return_value={'description': 'Test', 'previous_comments': []}
|
||||
)
|
||||
data_collector.save_data = AsyncMock()
|
||||
|
||||
manager = GithubManager(token_manager, data_collector)
|
||||
manager.github_integration = mock_integration.return_value
|
||||
|
||||
# Send webhook
|
||||
message = Message(
|
||||
source=SourceType.GITHUB,
|
||||
message={
|
||||
'payload': payload,
|
||||
'installation': payload['installation']['id'],
|
||||
},
|
||||
)
|
||||
await manager.receive_message(message)
|
||||
|
||||
# Wait for agent to start
|
||||
await asyncio.wait_for(agent_started.wait(), timeout=10.0)
|
||||
|
||||
# Give time for GitHub API calls to complete
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Verify via MockGitHubService state (no more async events!)
|
||||
assert agent_started.is_set(), 'Agent server should start'
|
||||
assert captured_request is not None
|
||||
assert captured_request.selected_repository == 'test-owner/test-repo'
|
||||
|
||||
# Verify GitHub API calls were made
|
||||
mock_github_service.assert_comment_sent("I'm on it")
|
||||
mock_github_service.assert_reaction_added('eyes')
|
||||
|
||||
# Print verification
|
||||
comments = mock_github_service.get_comments()
|
||||
reactions = mock_github_service.get_reactions()
|
||||
|
||||
print('✅ Agent server started')
|
||||
print(f'✅ "I\'m on it" message sent: {comments[0]["body"][:60]}...')
|
||||
print(f'✅ Eyes reaction added: {reactions[0]["content"]}')
|
||||
Reference in New Issue
Block a user