Fix UserData validation error when GitHub user has no OpenHands account (#13135)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra
2026-03-03 15:22:57 -05:00
committed by GitHub
parent 6dff07ea35
commit 5cad59a661
4 changed files with 767 additions and 0 deletions

View File

@@ -23,6 +23,7 @@ from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
get_session_expired_message,
get_user_not_found_message,
)
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
@@ -127,6 +128,76 @@ class GithubManager(Manager[GithubViewType]):
return False
def _get_issue_number_from_payload(self, message: Message) -> int | None:
"""Extract issue/PR number from a GitHub webhook payload.
Supports all event types that can trigger jobs:
- Labeled issues: payload['issue']['number']
- Issue comments: payload['issue']['number']
- PR comments: payload['issue']['number'] (PRs are accessed via issue endpoint)
- Inline PR comments: payload['pull_request']['number']
Args:
message: The incoming GitHub webhook message
Returns:
The issue/PR number, or None if not found
"""
payload = message.message.get('payload', {})
# Labeled issues, issue comments, and PR comments all have 'issue' in payload
if 'issue' in payload:
return payload['issue']['number']
# Inline PR comments have 'pull_request' directly in payload
if 'pull_request' in payload:
return payload['pull_request']['number']
return None
def _send_user_not_found_message(self, message: Message, username: str):
"""Send a message to the user informing them they need to create an OpenHands account.
This method handles all supported trigger types:
- Labeled issues (action='labeled' with openhands label)
- Issue comments (comment containing @openhands)
- PR comments (comment containing @openhands on a PR)
- Inline PR review comments (comment containing @openhands)
Args:
message: The incoming GitHub webhook message
username: The GitHub username to mention in the response
"""
payload = message.message.get('payload', {})
installation_id = message.message['installation']
repo_obj = payload['repository']
full_repo_name = self._get_full_repo_name(repo_obj)
# Get installation token to post the comment
installation_token = self._get_installation_access_token(installation_id)
# Determine the issue/PR number based on the event type
issue_number = self._get_issue_number_from_payload(message)
if not issue_number:
logger.warning(
f'[GitHub] Could not determine issue/PR number to send user not found message for {username}. '
f'Payload keys: {list(payload.keys())}'
)
return
# Post the comment
try:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(full_repo_name)
issue = repo.get_issue(number=issue_number)
issue.create_comment(get_user_not_found_message(username))
except Exception as e:
logger.error(
f'[GitHub] Failed to send user not found message to {username} '
f'on {full_repo_name}#{issue_number}: {e}'
)
async def is_job_requested(self, message: Message) -> bool:
self._confirm_incoming_source_type(message)
@@ -180,9 +251,20 @@ class GithubManager(Manager[GithubViewType]):
if await self.is_job_requested(message):
payload = message.message.get('payload', {})
user_id = payload['sender']['id']
username = payload['sender']['login']
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
# Check if the user has an OpenHands account
if not keycloak_user_id:
logger.warning(
f'[GitHub] User {username} (id={user_id}) not found in Keycloak. '
f'User must create an OpenHands account first.'
)
self._send_user_not_found_message(message, username)
return
github_view = await GithubFactory.create_github_view_from_payload(
message, keycloak_user_id
)

View File

@@ -65,6 +65,25 @@ def get_session_expired_message(username: str | None = None) -> str:
return f'Your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
def get_user_not_found_message(username: str | None = None) -> str:
"""Get a user-friendly message when a user hasn't created an OpenHands account.
Used by integrations to notify users when they try to use OpenHands features
but haven't logged into OpenHands Cloud yet (no Keycloak account exists).
Args:
username: Optional username to mention in the message. If provided,
the message will include @username prefix (used by Git providers
like GitHub, GitLab, Slack). If None, returns a generic message.
Returns:
A formatted user not found message
"""
if username:
return f"@{username} it looks like you haven't created an OpenHands account yet. Please sign up at [OpenHands Cloud]({HOST_URL}) and try again."
return f"It looks like you haven't created an OpenHands account yet. Please sign up at [OpenHands Cloud]({HOST_URL}) and try again."
# Toggle for solvability report feature
ENABLE_SOLVABILITY_ANALYSIS = (
os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true'

View File

@@ -0,0 +1,599 @@
"""
Tests for the GithubManager class.
Covers:
- User not found scenario when a GitHub user hasn't created an OpenHands account
- Sign-up message posting to GitHub issues/PRs
- All supported trigger types: labeled issues, issue comments, PR comments, inline PR comments
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from integrations.github.github_manager import GithubManager
from integrations.models import Message, SourceType
from integrations.utils import HOST_URL, get_user_not_found_message
class TestGithubManagerUserNotFound:
"""Test cases for when a valid GitHub user hasn't created an OpenHands account."""
@pytest.fixture
def mock_token_manager(self):
"""Create a mock token manager."""
token_manager = MagicMock()
token_manager.get_user_id_from_idp_user_id = AsyncMock(return_value=None)
return token_manager
@pytest.fixture
def mock_data_collector(self):
"""Create a mock data collector."""
data_collector = MagicMock()
data_collector.process_payload = MagicMock()
return data_collector
@pytest.fixture
def github_issue_comment_message(self):
"""Create a sample GitHub issue comment message with an @openhands mention."""
return Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'action': 'created',
'sender': {
'id': 67890,
'login': 'testuser',
},
'repository': {
'owner': {'login': 'test-owner'},
'name': 'test-repo',
},
'issue': {
'number': 42,
},
'comment': {
'body': '@openhands please help with this issue',
},
},
},
)
# Alias for backward compatibility with existing tests
@pytest.fixture
def github_issue_message(self, github_issue_comment_message):
"""Alias for github_issue_comment_message for backward compatibility."""
return github_issue_comment_message
@pytest.fixture
def github_labeled_issue_message(self):
"""Create a sample GitHub labeled issue message (when openhands label is added)."""
return Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'action': 'labeled',
'sender': {
'id': 67890,
'login': 'labeluser',
},
'repository': {
'owner': {'login': 'test-owner'},
'name': 'test-repo',
},
'issue': {
'number': 55,
},
'label': {
'name': 'openhands',
},
},
},
)
@pytest.fixture
def github_pr_comment_message(self):
"""Create a sample GitHub PR comment message (comment on a PR, accessed via issue endpoint)."""
return Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'action': 'created',
'sender': {
'id': 67890,
'login': 'prcommentuser',
},
'repository': {
'owner': {'login': 'test-owner'},
'name': 'test-repo',
},
'issue': {
'number': 77,
'pull_request': {
'url': 'https://api.github.com/repos/test-owner/test-repo/pulls/77',
},
},
'comment': {
'body': '@openhands please review this PR',
},
},
},
)
@pytest.fixture
def github_inline_pr_comment_message(self):
"""Create a sample GitHub inline PR review comment message."""
return Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'action': 'created',
'sender': {
'id': 67890,
'login': 'inlineuser',
},
'repository': {
'owner': {'login': 'test-owner'},
'name': 'test-repo',
},
'pull_request': {
'number': 100,
'head': {
'ref': 'feature-branch',
},
},
'comment': {
'id': 12345,
'node_id': 'PRRC_abc123',
'body': '@openhands fix this code',
'path': 'src/main.py',
'line': 42,
},
},
},
)
# Alias for backward compatibility
@pytest.fixture
def github_pr_message(self, github_inline_pr_comment_message):
"""Alias for github_inline_pr_comment_message for backward compatibility."""
return github_inline_pr_comment_message
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
@patch('integrations.github.github_manager.Github')
def test_send_user_not_found_message_for_issue(
self,
mock_github_class,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
github_issue_message,
):
"""Test that a sign-up message is sent when a valid user hasn't created an OpenHands account on an issue."""
# Set up mocks
mock_github_instance = MagicMock()
mock_github_class.return_value.__enter__ = MagicMock(
return_value=mock_github_instance
)
mock_github_class.return_value.__exit__ = MagicMock(return_value=False)
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_github_instance.get_repo.return_value = mock_repo
mock_repo.get_issue.return_value = mock_issue
mock_integration_instance = MagicMock()
mock_github_integration.return_value = mock_integration_instance
mock_integration_instance.get_access_token.return_value = MagicMock(
token='fake-token'
)
# Create manager and call the method
manager = GithubManager(mock_token_manager, mock_data_collector)
manager._send_user_not_found_message(github_issue_message, 'testuser')
# Verify the comment was posted
mock_github_instance.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_issue.assert_called_once_with(number=42)
# Verify the comment contains the expected sign-up message
mock_issue.create_comment.assert_called_once()
comment_text = mock_issue.create_comment.call_args[0][0]
assert '@testuser' in comment_text
assert "haven't created an OpenHands account" in comment_text
assert 'sign up' in comment_text.lower()
assert HOST_URL in comment_text
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
@patch('integrations.github.github_manager.Github')
def test_send_user_not_found_message_for_pr(
self,
mock_github_class,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
github_pr_message,
):
"""Test that a sign-up message is sent when a valid user hasn't created an OpenHands account on a PR."""
# Set up mocks
mock_github_instance = MagicMock()
mock_github_class.return_value.__enter__ = MagicMock(
return_value=mock_github_instance
)
mock_github_class.return_value.__exit__ = MagicMock(return_value=False)
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_github_instance.get_repo.return_value = mock_repo
mock_repo.get_issue.return_value = mock_issue
mock_integration_instance = MagicMock()
mock_github_integration.return_value = mock_integration_instance
mock_integration_instance.get_access_token.return_value = MagicMock(
token='fake-token'
)
# Create manager and call the method
manager = GithubManager(mock_token_manager, mock_data_collector)
manager._send_user_not_found_message(github_pr_message, 'pruser')
# Verify the comment was posted with PR number
mock_github_instance.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_issue.assert_called_once_with(number=100)
# Verify the comment contains the expected sign-up message
mock_issue.create_comment.assert_called_once()
comment_text = mock_issue.create_comment.call_args[0][0]
assert '@pruser' in comment_text
assert "haven't created an OpenHands account" in comment_text
@pytest.mark.asyncio
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
@patch('integrations.github.github_manager.Github')
async def test_receive_message_sends_user_not_found_when_keycloak_user_id_is_none(
self,
mock_github_class,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
github_issue_message,
):
"""Test that receive_message sends a sign-up message when get_user_id_from_idp_user_id returns None."""
# Set up mocks
mock_github_instance = MagicMock()
mock_github_class.return_value.__enter__ = MagicMock(
return_value=mock_github_instance
)
mock_github_class.return_value.__exit__ = MagicMock(return_value=False)
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_github_instance.get_repo.return_value = mock_repo
mock_repo.get_issue.return_value = mock_issue
mock_integration_instance = MagicMock()
mock_github_integration.return_value = mock_integration_instance
mock_integration_instance.get_access_token.return_value = MagicMock(
token='fake-token'
)
mock_integration_instance.get_github_for_installation.return_value.__enter__ = (
MagicMock(return_value=mock_github_instance)
)
mock_integration_instance.get_github_for_installation.return_value.__exit__ = (
MagicMock(return_value=False)
)
# Mock user having write access (so is_job_requested returns True)
mock_repo.get_collaborator_permission.return_value = 'write'
# Token manager returns None for keycloak_user_id (user hasn't created an account)
mock_token_manager.get_user_id_from_idp_user_id = AsyncMock(return_value=None)
# Create manager
manager = GithubManager(mock_token_manager, mock_data_collector)
# Call receive_message
await manager.receive_message(github_issue_message)
# Verify get_user_id_from_idp_user_id was called
mock_token_manager.get_user_id_from_idp_user_id.assert_called_once()
# Verify the sign-up message was posted
mock_issue.create_comment.assert_called_once()
comment_text = mock_issue.create_comment.call_args[0][0]
assert '@testuser' in comment_text
assert "haven't created an OpenHands account" in comment_text
assert 'sign up' in comment_text.lower()
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
@patch('integrations.github.github_manager.logger')
def test_send_user_not_found_message_logs_warning_when_no_issue_number(
self,
mock_logger,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
):
"""Test that a warning is logged when issue/PR number cannot be determined."""
mock_integration_instance = MagicMock()
mock_github_integration.return_value = mock_integration_instance
mock_integration_instance.get_access_token.return_value = MagicMock(
token='fake-token'
)
# Create a message without issue or pull_request
message_without_issue = Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'action': 'created',
'sender': {
'id': 67890,
'login': 'testuser',
},
'repository': {
'owner': {'login': 'test-owner'},
'name': 'test-repo',
},
},
},
)
manager = GithubManager(mock_token_manager, mock_data_collector)
manager._send_user_not_found_message(message_without_issue, 'testuser')
# Verify warning was logged
mock_logger.warning.assert_called_once()
assert 'Could not determine issue/PR number' in str(
mock_logger.warning.call_args
)
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
@patch('integrations.github.github_manager.Github')
def test_send_user_not_found_message_for_labeled_issue(
self,
mock_github_class,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
github_labeled_issue_message,
):
"""Test that a sign-up message is sent for labeled issue events."""
# Set up mocks
mock_github_instance = MagicMock()
mock_github_class.return_value.__enter__ = MagicMock(
return_value=mock_github_instance
)
mock_github_class.return_value.__exit__ = MagicMock(return_value=False)
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_github_instance.get_repo.return_value = mock_repo
mock_repo.get_issue.return_value = mock_issue
mock_integration_instance = MagicMock()
mock_github_integration.return_value = mock_integration_instance
mock_integration_instance.get_access_token.return_value = MagicMock(
token='fake-token'
)
# Create manager and call the method
manager = GithubManager(mock_token_manager, mock_data_collector)
manager._send_user_not_found_message(github_labeled_issue_message, 'labeluser')
# Verify the comment was posted with correct issue number
mock_github_instance.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_issue.assert_called_once_with(number=55)
# Verify the comment contains the expected sign-up message
mock_issue.create_comment.assert_called_once()
comment_text = mock_issue.create_comment.call_args[0][0]
assert '@labeluser' in comment_text
assert "haven't created an OpenHands account" in comment_text
assert 'sign up' in comment_text.lower()
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
@patch('integrations.github.github_manager.Github')
def test_send_user_not_found_message_for_pr_comment_via_issue_endpoint(
self,
mock_github_class,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
github_pr_comment_message,
):
"""Test that a sign-up message is sent for PR comments (accessed via issue endpoint)."""
# Set up mocks
mock_github_instance = MagicMock()
mock_github_class.return_value.__enter__ = MagicMock(
return_value=mock_github_instance
)
mock_github_class.return_value.__exit__ = MagicMock(return_value=False)
mock_repo = MagicMock()
mock_issue = MagicMock()
mock_github_instance.get_repo.return_value = mock_repo
mock_repo.get_issue.return_value = mock_issue
mock_integration_instance = MagicMock()
mock_github_integration.return_value = mock_integration_instance
mock_integration_instance.get_access_token.return_value = MagicMock(
token='fake-token'
)
# Create manager and call the method
manager = GithubManager(mock_token_manager, mock_data_collector)
manager._send_user_not_found_message(github_pr_comment_message, 'prcommentuser')
# Verify the comment was posted with correct PR number (from issue.number)
mock_github_instance.get_repo.assert_called_once_with('test-owner/test-repo')
mock_repo.get_issue.assert_called_once_with(number=77)
# Verify the comment contains the expected sign-up message
mock_issue.create_comment.assert_called_once()
comment_text = mock_issue.create_comment.call_args[0][0]
assert '@prcommentuser' in comment_text
assert "haven't created an OpenHands account" in comment_text
assert 'sign up' in comment_text.lower()
class TestGetIssueNumberFromPayload:
"""Test cases for the _get_issue_number_from_payload helper method."""
@pytest.fixture
def mock_token_manager(self):
"""Create a mock token manager."""
token_manager = MagicMock()
return token_manager
@pytest.fixture
def mock_data_collector(self):
"""Create a mock data collector."""
data_collector = MagicMock()
return data_collector
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
def test_extracts_issue_number_from_issue_payload(
self,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
):
"""Test extraction from payload with 'issue' key (labeled issues, issue comments, PR comments)."""
message = Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'issue': {'number': 42},
'repository': {'owner': {'login': 'owner'}, 'name': 'repo'},
},
},
)
manager = GithubManager(mock_token_manager, mock_data_collector)
result = manager._get_issue_number_from_payload(message)
assert result == 42
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
def test_extracts_pr_number_from_pull_request_payload(
self,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
):
"""Test extraction from payload with 'pull_request' key (inline PR comments)."""
message = Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'pull_request': {'number': 100},
'repository': {'owner': {'login': 'owner'}, 'name': 'repo'},
},
},
)
manager = GithubManager(mock_token_manager, mock_data_collector)
result = manager._get_issue_number_from_payload(message)
assert result == 100
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
def test_prefers_issue_over_pull_request_when_both_present(
self,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
):
"""Test that issue takes precedence over pull_request (edge case)."""
message = Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'issue': {'number': 42},
'pull_request': {'number': 100},
'repository': {'owner': {'login': 'owner'}, 'name': 'repo'},
},
},
)
manager = GithubManager(mock_token_manager, mock_data_collector)
result = manager._get_issue_number_from_payload(message)
assert result == 42
@patch('integrations.github.github_manager.Auth')
@patch('integrations.github.github_manager.GithubIntegration')
def test_returns_none_when_no_issue_or_pr(
self,
mock_github_integration,
mock_auth,
mock_token_manager,
mock_data_collector,
):
"""Test that None is returned when neither issue nor pull_request is in payload."""
message = Message(
source=SourceType.GITHUB,
message={
'installation': 12345,
'payload': {
'repository': {'owner': {'login': 'owner'}, 'name': 'repo'},
},
},
)
manager = GithubManager(mock_token_manager, mock_data_collector)
result = manager._get_issue_number_from_payload(message)
assert result is None
class TestGetUserNotFoundMessageIntegration:
"""Integration tests to verify the user not found message content matches expectations."""
def test_message_mentions_openhands_cloud(self):
"""Test that the message directs users to OpenHands Cloud."""
message = get_user_not_found_message('testuser')
assert 'OpenHands Cloud' in message
def test_message_contains_actionable_instruction(self):
"""Test that the message tells users to sign up."""
message = get_user_not_found_message('testuser')
assert 'sign up' in message.lower()
assert 'try again' in message.lower()
def test_message_is_friendly_and_informative(self):
"""Test that the message is friendly and explains the situation."""
message = get_user_not_found_message('testuser')
assert 'it looks like' in message.lower()
assert "haven't created an openhands account" in message.lower()

View File

@@ -8,6 +8,7 @@ from integrations.utils import (
append_conversation_footer,
get_session_expired_message,
get_summary_for_agent_state,
get_user_not_found_message,
)
from openhands.core.schema.agent import AgentState
@@ -228,6 +229,72 @@ class TestGetSessionExpiredMessage:
assert 'Your session' in result
class TestGetUserNotFoundMessage:
"""Test cases for get_user_not_found_message function.
This function is used to notify users when they try to use OpenHands features
but haven't created an OpenHands account yet (no Keycloak account exists).
"""
def test_message_with_username_contains_at_prefix(self):
"""Test that the message contains the username with @ prefix."""
result = get_user_not_found_message('testuser')
assert '@testuser' in result
def test_message_with_username_contains_sign_up_text(self):
"""Test that the message contains sign up text."""
result = get_user_not_found_message('testuser')
assert "haven't created an OpenHands account" in result
def test_message_with_username_contains_sign_up_instruction(self):
"""Test that the message contains sign up instruction."""
result = get_user_not_found_message('testuser')
assert 'sign up' in result.lower()
def test_message_with_username_contains_host_url(self):
"""Test that the message contains the OpenHands Cloud URL."""
result = get_user_not_found_message('testuser')
assert HOST_URL in result
assert 'OpenHands Cloud' in result
def test_different_usernames(self):
"""Test that different usernames produce different messages."""
result1 = get_user_not_found_message('user1')
result2 = get_user_not_found_message('user2')
assert '@user1' in result1
assert '@user2' in result2
assert '@user1' not in result2
assert '@user2' not in result1
def test_message_without_username_contains_sign_up_text(self):
"""Test that the message without username contains sign up text."""
result = get_user_not_found_message()
assert "haven't created an OpenHands account" in result
def test_message_without_username_contains_sign_up_instruction(self):
"""Test that the message without username contains sign up instruction."""
result = get_user_not_found_message()
assert 'sign up' in result.lower()
def test_message_without_username_contains_host_url(self):
"""Test that the message without username contains the OpenHands Cloud URL."""
result = get_user_not_found_message()
assert HOST_URL in result
assert 'OpenHands Cloud' in result
def test_message_without_username_does_not_contain_at_prefix(self):
"""Test that the message without username does not contain @ prefix."""
result = get_user_not_found_message()
assert not result.startswith('@')
assert 'It looks like' in result
def test_message_with_none_username(self):
"""Test that passing None explicitly works the same as no argument."""
result = get_user_not_found_message(None)
assert not result.startswith('@')
assert 'It looks like' in result
class TestAppendConversationFooter:
"""Test cases for append_conversation_footer function."""