[Jira]: improve traceability and reliability fixes (#12515)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra
2026-01-21 10:50:41 -08:00
committed by GitHub
parent 44f1bb022b
commit f9891a2c0d
10 changed files with 1547 additions and 1692 deletions

View File

@@ -1,18 +1,37 @@
from typing import Tuple
from urllib.parse import urlparse
"""Jira integration manager.
This module orchestrates the processing of Jira webhook events:
1. Parse webhook payload (via JiraPayloadParser)
2. Validate workspace
3. Authenticate user
4. Create view with repository selection (via JiraFactory)
5. Start conversation job
The manager delegates payload parsing to JiraPayloadParser and view creation
to JiraFactory, keeping the orchestration logic clean and traceable.
"""
import httpx
from integrations.jira.jira_types import JiraViewInterface
from integrations.jira.jira_view import (
JiraFactory,
JiraNewConversationView,
from integrations.jira.jira_payload import (
JiraPayloadError,
JiraPayloadParser,
JiraPayloadSkipped,
JiraPayloadSuccess,
JiraWebhookPayload,
)
from integrations.jira.jira_types import (
JiraViewInterface,
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
from integrations.manager import Manager
from integrations.models import JobContext, Message
from integrations.models import Message
from integrations.utils import (
HOST,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_oh_labels,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
@@ -24,9 +43,6 @@ from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
@@ -37,267 +53,211 @@ from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
# Get OH labels for this environment
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
class JiraManager(Manager):
"""Manager for processing Jira webhook events.
This class orchestrates the flow from webhook receipt to conversation creation,
delegating parsing to JiraPayloadParser and view creation to JiraFactory.
"""
def __init__(self, token_manager: TokenManager):
self.token_manager = token_manager
self.integration_store = JiraIntegrationStore.get_instance()
self.jinja_env = Environment(
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'jira')
)
self.payload_parser = JiraPayloadParser(
oh_label=OH_LABEL,
inline_oh_label=INLINE_OH_LABEL,
)
async def authenticate_user(
self, jira_user_id: str, workspace_id: int
async def receive_message(self, message: Message):
"""Process incoming Jira webhook message.
Flow:
1. Parse webhook payload
2. Validate workspace exists and is active
3. Authenticate user
4. Create view (includes fetching issue details and selecting repository)
5. Start job
Each step has clear logging for traceability.
"""
raw_payload = message.message.get('payload', {})
# Step 1: Parse webhook payload
logger.info(
'[Jira] Received webhook',
extra={'raw_payload': raw_payload},
)
parse_result = self.payload_parser.parse(raw_payload)
if isinstance(parse_result, JiraPayloadSkipped):
logger.info(
'[Jira] Webhook skipped', extra={'reason': parse_result.skip_reason}
)
return
if isinstance(parse_result, JiraPayloadError):
logger.warning(
'[Jira] Webhook parse failed', extra={'error': parse_result.error}
)
return
payload = parse_result.payload
logger.info(
'[Jira] Processing webhook',
extra={
'event_type': payload.event_type.value,
'issue_key': payload.issue_key,
'user_email': payload.user_email,
},
)
# Step 2: Validate workspace
workspace = await self._get_active_workspace(payload)
if not workspace:
return
# Step 3: Authenticate user
jira_user, saas_user_auth = await self._authenticate_user(payload, workspace)
if not jira_user or not saas_user_auth:
return
# Step 4: Create view (includes issue details fetch and repo selection)
decrypted_api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
try:
view = await JiraFactory.create_view(
payload=payload,
workspace=workspace,
user=jira_user,
user_auth=saas_user_auth,
decrypted_api_key=decrypted_api_key,
)
except RepositoryNotFoundError as e:
logger.warning(
'[Jira] Repository not found',
extra={'issue_key': payload.issue_key, 'error': str(e)},
)
await self._send_error_from_payload(payload, workspace, str(e))
return
except StartingConvoException as e:
logger.warning(
'[Jira] View creation failed',
extra={'issue_key': payload.issue_key, 'error': str(e)},
)
await self._send_error_from_payload(payload, workspace, str(e))
return
except Exception as e:
logger.error(
'[Jira] Unexpected error creating view',
extra={'issue_key': payload.issue_key, 'error': str(e)},
exc_info=True,
)
await self._send_error_from_payload(
payload,
workspace,
'Failed to initialize conversation. Please try again.',
)
return
# Step 5: Start job
await self.start_job(view)
async def _get_active_workspace(
self, payload: JiraWebhookPayload
) -> JiraWorkspace | None:
"""Validate and return the workspace for the webhook.
Returns None if:
- Workspace not found
- Workspace is inactive
- Request is from service account (to prevent recursion)
"""
workspace = await self.integration_store.get_workspace_by_name(
payload.workspace_name
)
if not workspace:
logger.warning(
'[Jira] Workspace not found',
extra={'workspace_name': payload.workspace_name},
)
# Can't send error without workspace credentials
return None
# Prevent recursive triggers from service account
if payload.user_email == workspace.svc_acc_email:
logger.debug(
'[Jira] Ignoring service account trigger',
extra={'workspace_name': payload.workspace_name},
)
return None
if workspace.status != 'active':
logger.warning(
'[Jira] Workspace inactive',
extra={'workspace_id': workspace.id, 'status': workspace.status},
)
await self._send_error_from_payload(
payload, workspace, 'Jira integration is not active for your workspace.'
)
return None
return workspace
async def _authenticate_user(
self, payload: JiraWebhookPayload, workspace: JiraWorkspace
) -> tuple[JiraUser | None, UserAuth | None]:
"""Authenticate Jira user and get their OpenHands user auth."""
# Find active Jira user by Keycloak user ID and workspace ID
"""Authenticate the Jira user and get OpenHands auth."""
jira_user = await self.integration_store.get_active_user(
jira_user_id, workspace_id
payload.account_id, workspace.id
)
if not jira_user:
logger.warning(
f'[Jira] No active Jira user found for {jira_user_id} in workspace {workspace_id}'
'[Jira] User not found or inactive',
extra={
'account_id': payload.account_id,
'user_email': payload.user_email,
'workspace_id': workspace.id,
},
)
await self._send_error_from_payload(
payload,
workspace,
f'User {payload.user_email} is not authenticated or active in the Jira integration.',
)
return None, None
saas_user_auth = await get_user_auth_from_keycloak_id(
jira_user.keycloak_user_id
)
if not saas_user_auth:
logger.warning(
'[Jira] Failed to get OpenHands auth',
extra={
'keycloak_user_id': jira_user.keycloak_user_id,
'user_email': payload.user_email,
},
)
await self._send_error_from_payload(
payload,
workspace,
f'User {payload.user_email} is not authenticated with OpenHands.',
)
return None, None
return jira_user, saas_user_auth
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
"""Get repositories that the user has access to."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return []
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
repos: list[Repository] = await client.get_repositories(
'pushed', server_config.app_mode, None, None, None, None
)
return repos
def get_workspace_name_from_payload(self, payload: dict) -> str | None:
"""Extract workspace name from Jira webhook payload."""
if payload.get('webhookEvent') == 'comment_created':
selfUrl = payload.get('comment', {}).get('author', {}).get('self')
elif payload.get('webhookEvent') == 'jira:issue_updated':
selfUrl = payload.get('user', {}).get('self')
else:
return None
if not selfUrl:
return None
parsedUrl = urlparse(selfUrl)
return parsedUrl.hostname or None
def parse_webhook(self, message: Message) -> JobContext | None:
payload = message.message.get('payload', {})
issue_data = payload.get('issue', {})
issue_id = issue_data.get('id')
issue_key = issue_data.get('key')
self_url = issue_data.get('self', '')
if not self_url:
logger.warning('[Jira] Missing self URL in issue data')
base_api_url = ''
elif '/rest/' in self_url:
base_api_url = self_url.split('/rest/')[0]
else:
# Fallback: extract base URL using urlparse
parsed = urlparse(self_url)
base_api_url = f'{parsed.scheme}://{parsed.netloc}'
comment = ''
if JiraFactory.is_ticket_comment(message):
comment_data = payload.get('comment', {})
comment = comment_data.get('body', '')
user_data: dict = comment_data.get('author', {})
elif JiraFactory.is_labeled_ticket(message):
user_data = payload.get('user', {})
else:
raise ValueError('Unrecognized jira event')
user_email = user_data.get('emailAddress')
display_name = user_data.get('displayName')
account_id = user_data.get('accountId')
workspace_name = ''
parsedUrl = urlparse(base_api_url)
if parsedUrl.hostname:
workspace_name = parsedUrl.hostname
if not all(
[
issue_id,
issue_key,
user_email,
display_name,
account_id,
workspace_name,
base_api_url,
]
):
return None
return JobContext(
issue_id=issue_id,
issue_key=issue_key,
user_msg=comment,
user_email=user_email,
display_name=display_name,
platform_user_id=account_id,
workspace_name=workspace_name,
base_api_url=base_api_url,
)
async def is_job_requested(self, message: Message) -> bool:
return JiraFactory.is_labeled_ticket(message) or JiraFactory.is_ticket_comment(
message
)
async def receive_message(self, message: Message):
"""Process incoming Jira webhook message."""
payload = message.message.get('payload', {})
logger.info('[Jira]: received payload', extra={'payload': payload})
is_job_requested = await self.is_job_requested(message)
if not is_job_requested:
return
job_context = self.parse_webhook(message)
if not job_context:
logger.info(
'[Jira] Failed to parse webhook payload - missing required fields or invalid structure',
extra={'event_type': payload.get('webhookEvent')},
)
return
# Get workspace by user email domain
workspace = await self.integration_store.get_workspace_by_name(
job_context.workspace_name
)
if not workspace:
logger.warning(
f'[Jira] No workspace found for email domain: {job_context.user_email}'
)
await self._send_error_comment(
job_context,
'Your workspace is not configured with Jira integration.',
None,
)
return
# Prevent any recursive triggers from the service account
if job_context.user_email == workspace.svc_acc_email:
return
if workspace.status != 'active':
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
await self._send_error_comment(
job_context,
'Jira integration is not active for your workspace.',
workspace,
)
return
# Authenticate user
jira_user, saas_user_auth = await self.authenticate_user(
job_context.platform_user_id, workspace.id
)
if not jira_user or not saas_user_auth:
logger.warning(
f'[Jira] User authentication failed for {job_context.user_email}'
)
await self._send_error_comment(
job_context,
f'User {job_context.user_email} is not authenticated or active in the Jira integration.',
workspace,
)
return
# Get issue details
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
issue_title, issue_description = await self.get_issue_details(
job_context, workspace.jira_cloud_id, workspace.svc_acc_email, api_key
)
job_context.issue_title = issue_title
job_context.issue_description = issue_description
except Exception as e:
logger.error(f'[Jira] Failed to get issue context: {str(e)}')
await self._send_error_comment(
job_context,
'Failed to retrieve issue details. Please check the issue key and try again.',
workspace,
)
return
try:
# Create Jira view
jira_view = await JiraFactory.create_jira_view_from_payload(
job_context,
saas_user_auth,
jira_user,
workspace,
)
except Exception as e:
logger.error(f'[Jira] Failed to create jira view: {str(e)}', exc_info=True)
await self._send_error_comment(
job_context,
'Failed to initialize conversation. Please try again.',
workspace,
)
return
if not await self.is_repository_specified(message, jira_view):
return
await self.start_job(jira_view)
async def is_repository_specified(
self, message: Message, jira_view: JiraViewInterface
) -> bool:
"""
Check if a job is requested and handle repository selection.
"""
try:
# Get user repositories
user_repos: list[Repository] = await self._get_repositories(
jira_view.saas_user_auth
)
target_str = f'{jira_view.job_context.issue_description}\n{jira_view.job_context.user_msg}'
# Try to infer repository from issue description
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
if match:
# Found exact repository match
jira_view.selected_repo = repos[0].full_name
logger.info(f'[Jira] Inferred repository: {repos[0].full_name}')
return True
else:
# No clear match - send repository selection comment
await self._send_repo_selection_comment(jira_view)
return False
except Exception as e:
logger.error(f'[Jira] Error determining repository: {str(e)}')
return False
async def start_job(self, jira_view: JiraViewInterface):
async def start_job(self, view: JiraViewInterface):
"""Start a Jira job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_callback_processor import (
@@ -305,101 +265,79 @@ class JiraManager(Manager):
)
try:
user_info: JiraUser = jira_view.jira_user
logger.info(
f'[Jira] Starting job for user {user_info.keycloak_user_id} '
f'issue {jira_view.job_context.issue_key}',
'[Jira] Starting job',
extra={
'issue_key': view.payload.issue_key,
'user_id': view.jira_user.keycloak_user_id,
'selected_repo': view.selected_repo,
},
)
# Create conversation
conversation_id = await jira_view.create_or_update_conversation(
self.jinja_env
)
conversation_id = await view.create_or_update_conversation(self.jinja_env)
logger.info(
f'[Jira] Created/Updated conversation {conversation_id} for issue {jira_view.job_context.issue_key}'
'[Jira] Conversation created',
extra={
'conversation_id': conversation_id,
'issue_key': view.payload.issue_key,
},
)
# Register callback processor for updates
if isinstance(jira_view, JiraNewConversationView):
if isinstance(view, JiraNewConversationView):
processor = JiraCallbackProcessor(
issue_key=jira_view.job_context.issue_key,
workspace_name=jira_view.jira_workspace.name,
issue_key=view.payload.issue_key,
workspace_name=view.jira_workspace.name,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Jira] Created callback processor for conversation {conversation_id}'
'[Jira] Callback processor registered',
extra={'conversation_id': conversation_id},
)
# Send initial response
msg_info = jira_view.get_response_msg()
# Send success response
msg_info = view.get_response_msg()
except MissingSettingsError as e:
logger.warning(f'[Jira] Missing settings error: {str(e)}')
logger.warning(
'[Jira] Missing settings error',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
except LLMAuthenticationError as e:
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
logger.warning(
'[Jira] LLM authentication error',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(f'[Jira] Session expired: {str(e)}')
logger.warning(
'[Jira] Session expired',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = get_session_expired_message()
except StartingConvoException as e:
logger.warning(
'[Jira] Conversation start failed',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
msg_info = str(e)
except Exception as e:
logger.error(
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
'[Jira] Unexpected error starting job',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
exc_info=True,
)
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
# Send response comment
try:
api_key = self.token_manager.decrypt_text(
jira_view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg_info),
issue_key=jira_view.job_context.issue_key,
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(f'[Jira] Failed to send response message: {str(e)}')
async def get_issue_details(
self,
job_context: JobContext,
jira_cloud_id: str,
svc_acc_email: str,
svc_acc_api_key: str,
) -> Tuple[str, str]:
url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key))
response.raise_for_status()
issue_payload = response.json()
if not issue_payload:
raise ValueError(f'Issue with key {job_context.issue_key} not found.')
title = issue_payload.get('fields', {}).get('summary', '')
description = issue_payload.get('fields', {}).get('description', '')
if not title:
raise ValueError(
f'Issue with key {job_context.issue_key} does not have a title.'
)
if not description:
raise ValueError(
f'Issue with key {job_context.issue_key} does not have a description.'
)
return title, description
await self._send_comment(view, msg_info)
async def send_message(
self,
@@ -409,6 +347,7 @@ class JiraManager(Manager):
svc_acc_email: str,
svc_acc_api_key: str,
):
"""Send a comment to a Jira issue."""
url = (
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
@@ -420,54 +359,53 @@ class JiraManager(Manager):
response.raise_for_status()
return response.json()
async def _send_error_comment(
self,
job_context: JobContext,
error_msg: str,
workspace: JiraWorkspace | None,
):
"""Send error comment to Jira issue."""
if not workspace:
logger.error('[Jira] Cannot send error comment - no workspace available')
return
async def _send_comment(self, view: JiraViewInterface, msg: str):
"""Send a comment using credentials from the view."""
try:
api_key = self.token_manager.decrypt_text(
view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg),
issue_key=view.payload.issue_key,
jira_cloud_id=view.jira_workspace.jira_cloud_id,
svc_acc_email=view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(
'[Jira] Failed to send comment',
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
)
async def _send_error_from_payload(
self,
payload: JiraWebhookPayload,
workspace: JiraWorkspace,
error_msg: str,
):
"""Send error comment before view is created (using payload directly)."""
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(
self.create_outgoing_message(msg=error_msg),
issue_key=job_context.issue_key,
issue_key=payload.issue_key,
jira_cloud_id=workspace.jira_cloud_id,
svc_acc_email=workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
except Exception as e:
logger.error(f'[Jira] Failed to send error comment: {str(e)}')
async def _send_repo_selection_comment(self, jira_view: JiraViewInterface):
"""Send a comment with repository options for the user to choose."""
try:
comment_msg = (
'I need to know which repository to work with. '
'Please add it to your issue description or send a followup comment.'
)
api_key = self.token_manager.decrypt_text(
jira_view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=comment_msg),
issue_key=jira_view.job_context.issue_key,
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
svc_acc_api_key=api_key,
)
logger.info(
f'[Jira] Sent repository selection comment for issue {jira_view.job_context.issue_key}'
)
except Exception as e:
logger.error(
f'[Jira] Failed to send repository selection comment: {str(e)}'
'[Jira] Failed to send error comment',
extra={'issue_key': payload.issue_key, 'error': str(e)},
)
def get_workspace_name_from_payload(self, payload: dict) -> str | None:
"""Extract workspace name from Jira webhook payload.
This method is used by the route for signature verification.
"""
parse_result = self.payload_parser.parse(payload)
if isinstance(parse_result, JiraPayloadSuccess):
return parse_result.payload.workspace_name
return None

View File

@@ -0,0 +1,267 @@
"""Centralized payload parsing for Jira webhooks.
This module provides a single source of truth for parsing and validating
Jira webhook payloads, replacing scattered parsing logic throughout the codebase.
"""
from dataclasses import dataclass
from enum import Enum
from urllib.parse import urlparse
from openhands.core.logger import openhands_logger as logger
class JiraEventType(Enum):
"""Types of Jira events we handle."""
LABELED_TICKET = 'labeled_ticket'
COMMENT_MENTION = 'comment_mention'
@dataclass(frozen=True)
class JiraWebhookPayload:
"""Normalized, validated representation of a Jira webhook payload.
This immutable dataclass replaces JobContext and provides a single
source of truth for all webhook data. All parsing happens in
JiraPayloadParser, ensuring consistent validation.
"""
event_type: JiraEventType
raw_event: str # Original webhookEvent value
# Issue data
issue_id: str
issue_key: str
# User data
user_email: str
display_name: str
account_id: str
# Workspace data (derived from issue self URL)
workspace_name: str
base_api_url: str
# Event-specific data
comment_body: str = '' # For comment events
@property
def user_msg(self) -> str:
"""Alias for comment_body for backward compatibility."""
return self.comment_body
class JiraPayloadParseError(Exception):
"""Raised when payload parsing fails."""
def __init__(self, reason: str, event_type: str | None = None):
self.reason = reason
self.event_type = event_type
super().__init__(reason)
@dataclass(frozen=True)
class JiraPayloadSuccess:
"""Result when parsing succeeds."""
payload: JiraWebhookPayload
@dataclass(frozen=True)
class JiraPayloadSkipped:
"""Result when event is intentionally skipped."""
skip_reason: str
@dataclass(frozen=True)
class JiraPayloadError:
"""Result when parsing fails due to invalid data."""
error: str
JiraPayloadParseResult = JiraPayloadSuccess | JiraPayloadSkipped | JiraPayloadError
class JiraPayloadParser:
"""Centralized parser for Jira webhook payloads.
This class provides a single entry point for parsing webhooks,
determining event types, and extracting all necessary fields.
Replaces scattered parsing in JiraFactory and JiraManager.
"""
def __init__(self, oh_label: str, inline_oh_label: str):
"""Initialize parser with OpenHands label configuration.
Args:
oh_label: Label that triggers OpenHands (e.g., 'openhands')
inline_oh_label: Mention that triggers OpenHands (e.g., '@openhands')
"""
self.oh_label = oh_label
self.inline_oh_label = inline_oh_label
def parse(self, raw_payload: dict) -> JiraPayloadParseResult:
"""Parse a raw webhook payload into a normalized JiraWebhookPayload.
Args:
raw_payload: The raw webhook payload dict from Jira
Returns:
One of:
- JiraPayloadSuccess: Valid, actionable event with payload
- JiraPayloadSkipped: Event we intentionally don't process
- JiraPayloadError: Malformed payload we expected to process
"""
webhook_event = raw_payload.get('webhookEvent', '')
logger.debug(
'[Jira] Parsing webhook payload', extra={'webhook_event': webhook_event}
)
if webhook_event == 'jira:issue_updated':
return self._parse_label_event(raw_payload, webhook_event)
elif webhook_event == 'comment_created':
return self._parse_comment_event(raw_payload, webhook_event)
else:
return JiraPayloadSkipped(f'Unhandled webhook event type: {webhook_event}')
def _parse_label_event(
self, payload: dict, webhook_event: str
) -> JiraPayloadParseResult:
"""Parse an issue_updated event for label changes."""
changelog = payload.get('changelog', {})
items = changelog.get('items', [])
# Extract labels that were added
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
if self.oh_label not in labels:
return JiraPayloadSkipped(
f"Label event does not contain '{self.oh_label}' label"
)
# For label events, user data comes from 'user' field
user_data = payload.get('user', {})
return self._extract_and_validate(
payload=payload,
user_data=user_data,
event_type=JiraEventType.LABELED_TICKET,
webhook_event=webhook_event,
comment_body='',
)
def _parse_comment_event(
self, payload: dict, webhook_event: str
) -> JiraPayloadParseResult:
"""Parse a comment_created event."""
comment_data = payload.get('comment', {})
comment_body = comment_data.get('body', '')
if not self._has_mention(comment_body):
return JiraPayloadSkipped(
f"Comment does not mention '{self.inline_oh_label}'"
)
# For comment events, user data comes from 'comment.author'
user_data = comment_data.get('author', {})
return self._extract_and_validate(
payload=payload,
user_data=user_data,
event_type=JiraEventType.COMMENT_MENTION,
webhook_event=webhook_event,
comment_body=comment_body,
)
def _has_mention(self, text: str) -> bool:
"""Check if text contains an exact mention of OpenHands."""
from integrations.utils import has_exact_mention
return has_exact_mention(text, self.inline_oh_label)
def _extract_and_validate(
self,
payload: dict,
user_data: dict,
event_type: JiraEventType,
webhook_event: str,
comment_body: str,
) -> JiraPayloadParseResult:
"""Extract common fields and validate required data is present."""
issue_data = payload.get('issue', {})
# Extract all fields with empty string defaults (makes them str type)
issue_id = issue_data.get('id', '')
issue_key = issue_data.get('key', '')
user_email = user_data.get('emailAddress', '')
display_name = user_data.get('displayName', '')
account_id = user_data.get('accountId', '')
base_api_url, workspace_name = self._extract_workspace_from_url(
issue_data.get('self', '')
)
# Validate required fields
missing: list[str] = []
if not issue_id:
missing.append('issue.id')
if not issue_key:
missing.append('issue.key')
if not user_email:
missing.append('user.emailAddress')
if not display_name:
missing.append('user.displayName')
if not account_id:
missing.append('user.accountId')
if not workspace_name:
missing.append('workspace_name (derived from issue.self)')
if not base_api_url:
missing.append('base_api_url (derived from issue.self)')
if missing:
return JiraPayloadError(f"Missing required fields: {', '.join(missing)}")
return JiraPayloadSuccess(
JiraWebhookPayload(
event_type=event_type,
raw_event=webhook_event,
issue_id=issue_id,
issue_key=issue_key,
user_email=user_email,
display_name=display_name,
account_id=account_id,
workspace_name=workspace_name,
base_api_url=base_api_url,
comment_body=comment_body,
)
)
def _extract_workspace_from_url(self, self_url: str) -> tuple[str, str]:
"""Extract base API URL and workspace name from issue self URL.
Args:
self_url: The 'self' URL from the issue data
Returns:
Tuple of (base_api_url, workspace_name)
"""
if not self_url:
return '', ''
# Extract base URL (everything before /rest/)
if '/rest/' in self_url:
base_api_url = self_url.split('/rest/')[0]
else:
parsed = urlparse(self_url)
base_api_url = f'{parsed.scheme}://{parsed.netloc}'
# Extract workspace name (hostname)
parsed = urlparse(base_api_url)
workspace_name = parsed.hostname or ''
return base_api_url, workspace_name

View File

@@ -1,26 +1,42 @@
from abc import ABC, abstractmethod
"""Type definitions and interfaces for Jira integration."""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from integrations.models import JobContext
from jinja2 import Environment
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.server.user_auth.user_auth import UserAuth
if TYPE_CHECKING:
from integrations.jira.jira_payload import JiraWebhookPayload
class JiraViewInterface(ABC):
"""Interface for Jira views that handle different types of Jira interactions."""
"""Interface for Jira views that handle different types of Jira interactions.
job_context: JobContext
Views hold the webhook payload directly rather than duplicating fields,
and fetch issue details lazily when needed.
"""
# Core data - view holds these references
payload: 'JiraWebhookPayload'
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
# Mutable state set during processing
selected_repo: str | None
conversation_id: str
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get initial instructions for the conversation."""
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch and cache issue title and description from Jira API.
Returns:
Tuple of (issue_title, issue_description)
"""
pass
@abstractmethod
@@ -35,6 +51,21 @@ class JiraViewInterface(ABC):
class StartingConvoException(Exception):
"""Exception raised when starting a conversation fails."""
"""Exception raised when starting a conversation fails.
This provides user-friendly error messages that can be sent back to Jira.
"""
pass
class RepositoryNotFoundError(Exception):
"""Raised when a repository cannot be determined from the issue.
This is a separate error domain from StartingConvoException - it represents
a precondition failure (no repo configured/found) rather than a conversation
creation failure. The manager catches this and converts it to a user-friendly
message.
"""
pass

View File

@@ -1,8 +1,21 @@
from dataclasses import dataclass
"""Jira view implementations and factory.
from integrations.jira.jira_types import JiraViewInterface, StartingConvoException
from integrations.models import JobContext, Message
from integrations.utils import CONVERSATION_URL, HOST, get_oh_labels, has_exact_mention
Views are responsible for:
- Holding the webhook payload and auth context
- Lazy-loading issue details from Jira API when needed
- Creating conversations with the selected repository
"""
from dataclasses import dataclass, field
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
from integrations.jira.jira_types import (
JiraViewInterface,
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from jinja2 import Environment
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
@@ -10,52 +23,147 @@ from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.server.services.conversation_service import (
create_new_conversation,
)
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import create_new_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
integration_store = JiraIntegrationStore.get_instance()
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
@dataclass
class JiraNewConversationView(JiraViewInterface):
job_context: JobContext
"""View for creating a new Jira conversation.
This view holds the webhook payload directly and lazily fetches
issue details when needed for rendering templates.
"""
payload: JiraWebhookPayload
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str | None
conversation_id: str
selected_repo: str | None = None
conversation_id: str = ''
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
# Lazy-loaded issue details (cached after first fetch)
_issue_title: str | None = field(default=None, repr=False)
_issue_description: str | None = field(default=None, repr=False)
# Decrypted API key (set by factory)
_decrypted_api_key: str = field(default='', repr=False)
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch issue details from Jira API (cached after first call).
Returns:
Tuple of (issue_title, issue_description)
Raises:
StartingConvoException: If issue details cannot be fetched
"""
if self._issue_title is not None and self._issue_description is not None:
return self._issue_title, self._issue_description
try:
url = f'{JIRA_CLOUD_API_URL}/{self.jira_workspace.jira_cloud_id}/rest/api/2/issue/{self.payload.issue_key}'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.get(
url,
auth=(
self.jira_workspace.svc_acc_email,
self._decrypted_api_key,
),
)
response.raise_for_status()
issue_payload = response.json()
if not issue_payload:
raise StartingConvoException(
f'Issue {self.payload.issue_key} not found.'
)
self._issue_title = issue_payload.get('fields', {}).get('summary', '')
self._issue_description = (
issue_payload.get('fields', {}).get('description', '') or ''
)
if not self._issue_title:
raise StartingConvoException(
f'Issue {self.payload.issue_key} does not have a title.'
)
logger.info(
'[Jira] Fetched issue details',
extra={
'issue_key': self.payload.issue_key,
'has_description': bool(self._issue_description),
},
)
return self._issue_title, self._issue_description
except httpx.HTTPStatusError as e:
logger.error(
'[Jira] Failed to fetch issue details',
extra={
'issue_key': self.payload.issue_key,
'status': e.response.status_code,
},
)
raise StartingConvoException(
f'Failed to fetch issue details: HTTP {e.response.status_code}'
)
except Exception as e:
if isinstance(e, StartingConvoException):
raise
logger.error(
'[Jira] Failed to fetch issue details',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
)
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get instructions for the conversation.
This fetches issue details if not already cached.
Returns:
Tuple of (system_instructions, user_message)
"""
issue_title, issue_description = await self.get_issue_details()
instructions_template = jinja_env.get_template('jira_instructions.j2')
instructions = instructions_template.render()
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
user_message=self.job_context.user_msg or '',
issue_key=self.payload.issue_key,
issue_title=issue_title,
issue_description=issue_description,
user_message=self.payload.user_msg,
)
return instructions, user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create a new Jira conversation"""
"""Create a new Jira conversation.
Returns:
The conversation ID
Raises:
StartingConvoException: If conversation creation fails
"""
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = self._get_instructions(jinja_env)
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
@@ -73,81 +181,259 @@ class JiraNewConversationView(JiraViewInterface):
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Jira] Created conversation {self.conversation_id}')
logger.info(
'[Jira] Created conversation',
extra={
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
},
)
# Store Jira conversation mapping
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
issue_id=self.payload.issue_id,
issue_key=self.payload.issue_key,
jira_user_id=self.jira_user.id,
)
await integration_store.create_conversation(jira_conversation)
return self.conversation_id
except Exception as e:
if isinstance(e, StartingConvoException):
raise
logger.error(
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
'[Jira] Failed to create conversation',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
exc_info=True,
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira"""
"""Get the response message to send back to Jira."""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
return f"I'm on it! {self.payload.display_name} can [track my progress here|{conversation_link}]."
class JiraFactory:
"""Factory for creating Jira views based on message content"""
"""Factory for creating Jira views.
The factory is responsible for:
- Creating the appropriate view type
- Inferring and selecting the repository
- Validating all required data is available
Repository selection happens here so that view creation either
succeeds with a valid repo or fails with a clear error.
"""
@staticmethod
def is_labeled_ticket(message: Message) -> bool:
payload = message.message.get('payload', {})
event_type = payload.get('webhookEvent')
async def _create_provider_handler(user_auth: UserAuth) -> ProviderHandler | None:
"""Create a ProviderHandler for the user."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return None
if event_type != 'jira:issue_updated':
return False
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
changelog = payload.get('changelog', {})
items = changelog.get('items', [])
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
return OH_LABEL in labels
@staticmethod
def is_ticket_comment(message: Message) -> bool:
payload = message.message.get('payload', {})
event_type = payload.get('webhookEvent')
if event_type != 'comment_created':
return False
comment_data = payload.get('comment', {})
comment_body = comment_data.get('body', '')
return has_exact_mention(comment_body, INLINE_OH_LABEL)
@staticmethod
async def create_jira_view_from_payload(
job_context: JobContext,
saas_user_auth: UserAuth,
jira_user: JiraUser,
jira_workspace: JiraWorkspace,
) -> JiraViewInterface:
"""Create appropriate Jira view based on the message and user state"""
if not jira_user or not saas_user_auth or not jira_workspace:
raise StartingConvoException('User not authenticated with Jira integration')
return JiraNewConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
jira_user=jira_user,
jira_workspace=jira_workspace,
selected_repo=None, # Will be set later after repo inference
conversation_id='', # Will be set when conversation is created
return ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
@staticmethod
def _extract_potential_repos(
issue_key: str,
issue_title: str,
issue_description: str,
user_msg: str,
) -> list[str]:
"""Extract potential repository names from issue content.
Raises:
RepositoryNotFoundError: If no potential repos found in text.
"""
search_text = f'{issue_title}\n{issue_description}\n{user_msg}'
potential_repos = infer_repo_from_message(search_text)
if not potential_repos:
raise RepositoryNotFoundError(
'Could not determine which repository to use. '
'Please mention the repository (e.g., owner/repo) in the issue description or comment.'
)
logger.info(
'[Jira] Found potential repositories in issue content',
extra={'issue_key': issue_key, 'potential_repos': potential_repos},
)
return potential_repos
@staticmethod
async def _verify_repos(
issue_key: str,
potential_repos: list[str],
provider_handler: ProviderHandler,
) -> list[str]:
"""Verify which repos the user has access to."""
verified_repos: list[str] = []
for repo_name in potential_repos:
try:
repository = await provider_handler.verify_repo_provider(repo_name)
verified_repos.append(repository.full_name)
logger.debug(
'[Jira] Repository verification succeeded',
extra={'issue_key': issue_key, 'repository': repository.full_name},
)
except Exception as e:
logger.debug(
'[Jira] Repository verification failed',
extra={
'issue_key': issue_key,
'repo_name': repo_name,
'error': str(e),
},
)
return verified_repos
@staticmethod
def _select_single_repo(
issue_key: str,
potential_repos: list[str],
verified_repos: list[str],
) -> str:
"""Select exactly one repo from verified repos.
Raises:
RepositoryNotFoundError: If zero or multiple repos verified.
"""
if len(verified_repos) == 0:
raise RepositoryNotFoundError(
f'Could not access any of the mentioned repositories: {", ".join(potential_repos)}. '
'Please ensure you have access to the repository and it exists.'
)
if len(verified_repos) > 1:
raise RepositoryNotFoundError(
f'Multiple repositories found: {", ".join(verified_repos)}. '
'Please specify exactly one repository in the issue description or comment.'
)
logger.info(
'[Jira] Verified repository access',
extra={'issue_key': issue_key, 'repository': verified_repos[0]},
)
return verified_repos[0]
@staticmethod
async def _infer_repository(
payload: JiraWebhookPayload,
user_auth: UserAuth,
issue_title: str,
issue_description: str,
) -> str:
"""Infer and verify the repository from issue content.
Raises:
RepositoryNotFoundError: If no valid repository can be determined.
"""
provider_handler = await JiraFactory._create_provider_handler(user_auth)
if not provider_handler:
raise RepositoryNotFoundError(
'No Git provider connected. Please connect a Git provider in OpenHands settings.'
)
potential_repos = JiraFactory._extract_potential_repos(
payload.issue_key, issue_title, issue_description, payload.user_msg
)
verified_repos = await JiraFactory._verify_repos(
payload.issue_key, potential_repos, provider_handler
)
return JiraFactory._select_single_repo(
payload.issue_key, potential_repos, verified_repos
)
@staticmethod
async def create_view(
payload: JiraWebhookPayload,
workspace: JiraWorkspace,
user: JiraUser,
user_auth: UserAuth,
decrypted_api_key: str,
) -> JiraViewInterface:
"""Create a Jira view with repository already selected.
This factory method:
1. Creates the view with payload and auth context
2. Fetches issue details (needed for repo inference)
3. Infers and selects the repository
If any step fails, an appropriate exception is raised with
a user-friendly message.
Args:
payload: Parsed webhook payload
workspace: The Jira workspace
user: The Jira user
user_auth: OpenHands user authentication
decrypted_api_key: Decrypted service account API key
Returns:
A JiraViewInterface with selected_repo populated
Raises:
StartingConvoException: If view creation fails
RepositoryNotFoundError: If repository cannot be determined
"""
logger.info(
'[Jira] Creating view',
extra={
'issue_key': payload.issue_key,
'event_type': payload.event_type.value,
},
)
# Create the view
view = JiraNewConversationView(
payload=payload,
saas_user_auth=user_auth,
jira_user=user,
jira_workspace=workspace,
_decrypted_api_key=decrypted_api_key,
)
# Fetch issue details (needed for repo inference)
try:
issue_title, issue_description = await view.get_issue_details()
except StartingConvoException:
raise # Re-raise with original message
except Exception as e:
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
# Infer and select repository
selected_repo = await JiraFactory._infer_repository(
payload=payload,
user_auth=user_auth,
issue_title=issue_title,
issue_description=issue_description,
)
view.selected_repo = selected_repo
logger.info(
'[Jira] View created successfully',
extra={
'issue_key': payload.issue_key,
'selected_repo': selected_repo,
},
)
return view

View File

@@ -16,11 +16,6 @@ class Manager(ABC):
"Send message to integration from Openhands server"
raise NotImplementedError
@abstractmethod
async def is_job_requested(self, message: Message) -> bool:
"Confirm that a job is being requested"
raise NotImplementedError
@abstractmethod
def start_job(self):
"Kick off a job with openhands agent"

View File

@@ -398,53 +398,42 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
"""
Extract all repository names in the format 'owner/repo' from various Git provider URLs
and direct mentions in text. Supports GitHub, GitLab, and BitBucket.
Args:
user_msg: Input message that may contain repository references
Returns:
List of repository names in 'owner/repo' format, empty list if none found
"""
# Normalize the message by removing extra whitespace and newlines
normalized_msg = re.sub(r'\s+', ' ', user_msg.strip())
# Pattern to match Git URLs from GitHub, GitLab, and BitBucket
# Captures: protocol, domain, owner, repo (with optional .git extension)
git_url_pattern = r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:[/?#].*?)?(?=\s|$|[^\w.-])'
# Pattern to match direct owner/repo mentions (e.g., "OpenHands/OpenHands")
# Must be surrounded by word boundaries or specific characters to avoid false positives
direct_pattern = (
r'(?:^|\s|[\[\(\'"])([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)(?=\s|$|[\]\)\'",.])'
git_url_pattern = (
r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/'
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?'
r'(?:[/?#].*?)?(?=\s|$|[^\w.-])'
)
matches = []
# UPDATED: allow {{ owner/repo }} in addition to existing boundaries
direct_pattern = (
r'(?:^|\s|{{|[\[\(\'":`])' # left boundary
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)'
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
)
# First, find all Git URLs (highest priority)
git_matches = re.findall(git_url_pattern, normalized_msg)
for owner, repo in git_matches:
# Remove .git extension if present
matches: list[str] = []
# Git URLs first (highest priority)
for owner, repo in re.findall(git_url_pattern, normalized_msg):
repo = re.sub(r'\.git$', '', repo)
matches.append(f'{owner}/{repo}')
# Second, find all direct owner/repo mentions
direct_matches = re.findall(direct_pattern, normalized_msg)
for owner, repo in direct_matches:
# Direct mentions
for owner, repo in re.findall(direct_pattern, normalized_msg):
full_match = f'{owner}/{repo}'
# Skip if it looks like a version number, date, or file path
if (
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match) # version numbers
or re.match(r'^\d{1,2}/\d{1,2}$', full_match) # dates
or re.match(r'^[A-Z]/[A-Z]$', full_match) # single letters
or repo.endswith('.txt')
or repo.endswith('.md') # file extensions
or repo.endswith('.py')
or repo.endswith('.js')
or '.' in repo
and len(repo.split('.')) > 2
): # complex file paths
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match)
or re.match(r'^\d{1,2}/\d{1,2}$', full_match)
or re.match(r'^[A-Z]/[A-Z]$', full_match)
or repo.endswith(('.txt', '.md', '.py', '.js'))
or ('.' in repo and len(repo.split('.')) > 2)
):
continue
# Avoid duplicates from Git URLs already found
if full_match not in matches:
matches.append(full_match)

View File

@@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from integrations.jira.jira_manager import JiraManager
from integrations.jira.jira_payload import (
JiraEventType,
JiraWebhookPayload,
)
from integrations.jira.jira_view import (
JiraNewConversationView,
)
from integrations.models import JobContext
from jinja2 import DictLoader, Environment
from storage.jira_conversation import JiraConversation
from storage.jira_user import JiraUser
@@ -24,7 +27,7 @@ def mock_token_manager():
"""Create a mock TokenManager for testing."""
token_manager = MagicMock()
token_manager.get_user_id_from_user_email = AsyncMock()
token_manager.decrypt_text = MagicMock()
token_manager.decrypt_text = MagicMock(return_value='decrypted_key')
return token_manager
@@ -59,6 +62,7 @@ def sample_jira_workspace():
workspace = MagicMock(spec=JiraWorkspace)
workspace.id = 1
workspace.name = 'test.atlassian.net'
workspace.jira_cloud_id = 'cloud-123'
workspace.admin_user_id = 'admin_id'
workspace.webhook_secret = 'encrypted_secret'
workspace.svc_acc_email = 'service@example.com'
@@ -74,22 +78,41 @@ def sample_user_auth():
user_auth.get_provider_tokens = AsyncMock(return_value={})
user_auth.get_access_token = AsyncMock(return_value='test_token')
user_auth.get_user_id = AsyncMock(return_value='test_user_id')
user_auth.get_secrets = AsyncMock(return_value=None)
return user_auth
@pytest.fixture
def sample_job_context():
"""Create a sample JobContext for testing."""
return JobContext(
def sample_webhook_payload():
"""Create a sample JiraWebhookPayload for testing."""
return JiraWebhookPayload(
event_type=JiraEventType.COMMENT_MENTION,
raw_event='comment_created',
issue_id='12345',
issue_key='TEST-123',
user_msg='Fix this bug @openhands',
user_email='user@test.com',
display_name='Test User',
account_id='user123',
workspace_name='test.atlassian.net',
base_api_url='https://test.atlassian.net',
issue_title='Test Issue',
issue_description='This is a test issue',
comment_body='Fix this bug @openhands',
)
@pytest.fixture
def sample_label_webhook_payload():
"""Create a sample labeled ticket JiraWebhookPayload for testing."""
return JiraWebhookPayload(
event_type=JiraEventType.LABELED_TICKET,
raw_event='jira:issue_updated',
issue_id='12345',
issue_key='PROJ-123',
user_email='user@company.com',
display_name='Test User',
account_id='user456',
workspace_name='jira.company.com',
base_api_url='https://jira.company.com',
comment_body='',
)
@@ -180,16 +203,17 @@ def jira_conversation():
@pytest.fixture
def new_conversation_view(
sample_job_context, sample_user_auth, sample_jira_user, sample_jira_workspace
sample_webhook_payload, sample_user_auth, sample_jira_user, sample_jira_workspace
):
"""JiraNewConversationView instance for testing"""
return JiraNewConversationView(
job_context=sample_job_context,
payload=sample_webhook_payload,
saas_user_auth=sample_user_auth,
jira_user=sample_jira_user,
jira_workspace=sample_jira_workspace,
selected_repo='test/repo1',
conversation_id='conv-123',
_decrypted_api_key='decrypted_key',
)

File diff suppressed because it is too large Load Diff

View File

@@ -2,29 +2,90 @@
Tests for Jira view classes and factory.
"""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from integrations.jira.jira_types import StartingConvoException
from integrations.jira.jira_payload import (
JiraEventType,
JiraPayloadError,
JiraPayloadParser,
JiraPayloadSkipped,
JiraPayloadSuccess,
)
from integrations.jira.jira_types import RepositoryNotFoundError, StartingConvoException
from integrations.jira.jira_view import (
JiraFactory,
JiraNewConversationView,
)
from integrations.models import Message, SourceType
class TestJiraNewConversationView:
"""Tests for JiraNewConversationView"""
def test_get_instructions(self, new_conversation_view, mock_jinja_env):
"""Test _get_instructions method"""
instructions, user_msg = new_conversation_view._get_instructions(mock_jinja_env)
@pytest.mark.asyncio
async def test_get_issue_details_success(
self, new_conversation_view, sample_jira_workspace
):
"""Test successful issue details retrieval."""
mock_response = MagicMock()
mock_response.json.return_value = {
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
}
mock_response.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
title, description = await new_conversation_view.get_issue_details()
assert title == 'Test Issue'
assert description == 'Test description'
@pytest.mark.asyncio
async def test_get_issue_details_cached(self, new_conversation_view):
"""Test issue details are cached after first call."""
new_conversation_view._issue_title = 'Cached Title'
new_conversation_view._issue_description = 'Cached Description'
title, description = await new_conversation_view.get_issue_details()
assert title == 'Cached Title'
assert description == 'Cached Description'
@pytest.mark.asyncio
async def test_get_issue_details_no_title(self, new_conversation_view):
"""Test issue details with no title raises error."""
mock_response = MagicMock()
mock_response.json.return_value = {
'fields': {'summary': '', 'description': 'Test description'}
}
mock_response.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
with pytest.raises(StartingConvoException, match='does not have a title'):
await new_conversation_view.get_issue_details()
@pytest.mark.asyncio
async def test_get_instructions(self, new_conversation_view, mock_jinja_env):
"""Test _get_instructions method fetches issue details."""
new_conversation_view._issue_title = 'Test Issue'
new_conversation_view._issue_description = 'This is a test issue'
instructions, user_msg = await new_conversation_view._get_instructions(
mock_jinja_env
)
assert instructions == 'Test Jira instructions template'
assert 'TEST-123' in user_msg
assert 'Test Issue' in user_msg
assert 'Fix this bug @openhands' in user_msg
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.create_new_conversation')
@patch('integrations.jira.jira_view.integration_store')
async def test_create_or_update_conversation_success(
@@ -36,6 +97,8 @@ class TestJiraNewConversationView:
mock_agent_loop_info,
):
"""Test successful conversation creation"""
new_conversation_view._issue_title = 'Test Issue'
new_conversation_view._issue_description = 'Test description'
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
@@ -47,6 +110,7 @@ class TestJiraNewConversationView:
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
@pytest.mark.asyncio
async def test_create_or_update_conversation_no_repo(
self, new_conversation_view, mock_jinja_env
):
@@ -56,18 +120,6 @@ class TestJiraNewConversationView:
with pytest.raises(StartingConvoException, match='No repository selected'):
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
@patch('integrations.jira.jira_view.create_new_conversation')
async def test_create_or_update_conversation_failure(
self, mock_create_conversation, new_conversation_view, mock_jinja_env
):
"""Test conversation creation failure"""
mock_create_conversation.side_effect = Exception('Creation failed')
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
):
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
def test_get_response_msg(self, new_conversation_view):
"""Test get_response_msg method"""
response = new_conversation_view.get_response_msg()
@@ -81,469 +133,333 @@ class TestJiraNewConversationView:
class TestJiraFactory:
"""Tests for JiraFactory"""
@patch('integrations.jira.jira_view.integration_store')
async def test_create_jira_view_from_payload_new_conversation(
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
@patch('integrations.jira.jira_view.infer_repo_from_message')
async def test_create_view_success(
self,
mock_store,
sample_job_context,
mock_infer_repos,
mock_create_handler,
sample_webhook_payload,
sample_user_auth,
sample_jira_user,
sample_jira_workspace,
sample_repositories,
):
"""Test factory creating view with repo selection."""
# Setup mock provider handler
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(
return_value=sample_repositories[0]
)
mock_create_handler.return_value = mock_handler
# Mock repo inference to return a repo name
mock_infer_repos.return_value = ['test/repo1']
mock_response = MagicMock()
mock_response.json.return_value = {
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
}
mock_response.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
view = await JiraFactory.create_view(
payload=sample_webhook_payload,
workspace=sample_jira_workspace,
user=sample_jira_user,
user_auth=sample_user_auth,
decrypted_api_key='test_api_key',
)
assert isinstance(view, JiraNewConversationView)
assert view.selected_repo == 'test/repo1'
mock_handler.verify_repo_provider.assert_called_once_with('test/repo1')
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
@patch('integrations.jira.jira_view.infer_repo_from_message')
async def test_create_view_no_repo_in_text(
self,
mock_infer_repos,
mock_create_handler,
sample_webhook_payload,
sample_user_auth,
sample_jira_user,
sample_jira_workspace,
):
"""Test factory creating new conversation view"""
mock_store.get_user_conversations_by_issue_id = AsyncMock(return_value=None)
"""Test factory raises error when no repo mentioned in text."""
mock_handler = MagicMock()
mock_create_handler.return_value = mock_handler
view = await JiraFactory.create_jira_view_from_payload(
sample_job_context,
sample_user_auth,
sample_jira_user,
sample_jira_workspace,
# No repos found in text
mock_infer_repos.return_value = []
mock_response = MagicMock()
mock_response.json.return_value = {
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
}
mock_response.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
with pytest.raises(
RepositoryNotFoundError, match='Could not determine which repository'
):
await JiraFactory.create_view(
payload=sample_webhook_payload,
workspace=sample_jira_workspace,
user=sample_jira_user,
user_auth=sample_user_auth,
decrypted_api_key='test_api_key',
)
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
@patch('integrations.jira.jira_view.infer_repo_from_message')
async def test_create_view_repo_verification_fails(
self,
mock_infer_repos,
mock_create_handler,
sample_webhook_payload,
sample_user_auth,
sample_jira_user,
sample_jira_workspace,
):
"""Test factory raises error when repo verification fails."""
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(
side_effect=Exception('Repository not found')
)
mock_create_handler.return_value = mock_handler
# Repos found in text but verification fails
mock_infer_repos.return_value = ['test/repo1', 'test/repo2']
mock_response = MagicMock()
mock_response.json.return_value = {
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
}
mock_response.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
with pytest.raises(
RepositoryNotFoundError,
match='Could not access any of the mentioned repositories',
):
await JiraFactory.create_view(
payload=sample_webhook_payload,
workspace=sample_jira_workspace,
user=sample_jira_user,
user_auth=sample_user_auth,
decrypted_api_key='test_api_key',
)
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
@patch('integrations.jira.jira_view.infer_repo_from_message')
async def test_create_view_multiple_repos_verified(
self,
mock_infer_repos,
mock_create_handler,
sample_webhook_payload,
sample_user_auth,
sample_jira_user,
sample_jira_workspace,
sample_repositories,
):
"""Test factory raises error when multiple repos are verified."""
mock_handler = MagicMock()
# Both repos verify successfully
mock_handler.verify_repo_provider = AsyncMock(
side_effect=[sample_repositories[0], sample_repositories[1]]
)
mock_create_handler.return_value = mock_handler
# Multiple repos found in text
mock_infer_repos.return_value = ['test/repo1', 'test/repo2']
mock_response = MagicMock()
mock_response.json.return_value = {
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
}
mock_response.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
with pytest.raises(
RepositoryNotFoundError, match='Multiple repositories found'
):
await JiraFactory.create_view(
payload=sample_webhook_payload,
workspace=sample_jira_workspace,
user=sample_jira_user,
user_auth=sample_user_auth,
decrypted_api_key='test_api_key',
)
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.JiraFactory._create_provider_handler')
async def test_create_view_no_provider(
self,
mock_create_handler,
sample_webhook_payload,
sample_user_auth,
sample_jira_user,
sample_jira_workspace,
):
"""Test factory raises error when no provider is connected."""
mock_create_handler.return_value = None
mock_response = MagicMock()
mock_response.json.return_value = {
'fields': {'summary': 'Test Issue', 'description': 'Test description'}
}
mock_response.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
with pytest.raises(
RepositoryNotFoundError, match='No Git provider connected'
):
await JiraFactory.create_view(
payload=sample_webhook_payload,
workspace=sample_jira_workspace,
user=sample_jira_user,
user_auth=sample_user_auth,
decrypted_api_key='test_api_key',
)
class TestJiraPayloadParser:
"""Tests for JiraPayloadParser"""
@pytest.fixture
def parser(self):
"""Create a parser for testing."""
return JiraPayloadParser(oh_label='openhands', inline_oh_label='@openhands')
def test_parse_label_event_success(
self, parser, sample_issue_update_webhook_payload
):
"""Test parsing label event."""
result = parser.parse(sample_issue_update_webhook_payload)
assert isinstance(result, JiraPayloadSuccess)
assert result.payload.event_type == JiraEventType.LABELED_TICKET
assert result.payload.issue_key == 'PROJ-123'
def test_parse_comment_event_success(self, parser, sample_comment_webhook_payload):
"""Test parsing comment event."""
result = parser.parse(sample_comment_webhook_payload)
assert isinstance(result, JiraPayloadSuccess)
assert result.payload.event_type == JiraEventType.COMMENT_MENTION
assert result.payload.issue_key == 'TEST-123'
assert '@openhands' in result.payload.comment_body
def test_parse_unknown_event_skipped(self, parser):
"""Test unknown event is skipped."""
payload = {'webhookEvent': 'unknown_event'}
result = parser.parse(payload)
assert isinstance(result, JiraPayloadSkipped)
assert 'Unhandled webhook event type' in result.skip_reason
def test_parse_label_event_wrong_label_skipped(self, parser):
"""Test label event without OH label is skipped."""
payload = {
'webhookEvent': 'jira:issue_updated',
'changelog': {'items': [{'field': 'labels', 'toString': 'other-label'}]},
}
result = parser.parse(payload)
assert isinstance(result, JiraPayloadSkipped)
assert 'does not contain' in result.skip_reason
def test_parse_comment_event_no_mention_skipped(self, parser):
"""Test comment without mention is skipped."""
payload = {
'webhookEvent': 'comment_created',
'comment': {
'body': 'Regular comment',
'author': {'emailAddress': 'test@test.com'},
},
}
result = parser.parse(payload)
assert isinstance(result, JiraPayloadSkipped)
assert 'does not mention' in result.skip_reason
def test_parse_missing_fields_error(self, parser):
"""Test missing required fields returns error."""
payload = {
'webhookEvent': 'jira:issue_updated',
'changelog': {'items': [{'field': 'labels', 'toString': 'openhands'}]},
'issue': {'id': '123'}, # Missing key
'user': {'emailAddress': 'test@test.com'}, # Missing other fields
}
result = parser.parse(payload)
assert isinstance(result, JiraPayloadError)
assert 'Missing required fields' in result.error
class TestJiraPayloadParserStagingLabels:
"""Tests for JiraPayloadParser with staging labels."""
@pytest.fixture
def staging_parser(self):
"""Create a parser with staging labels."""
return JiraPayloadParser(
oh_label='openhands-exp', inline_oh_label='@openhands-exp'
)
assert isinstance(view, JiraNewConversationView)
assert view.conversation_id == ''
def test_parse_staging_label(self, staging_parser):
"""Test parsing with staging label."""
payload = {
'webhookEvent': 'jira:issue_updated',
'changelog': {'items': [{'field': 'labels', 'toString': 'openhands-exp'}]},
'issue': {
'id': '123',
'key': 'TEST-1',
'self': 'https://test.atlassian.net/rest/api/2/issue/123',
},
'user': {
'emailAddress': 'test@test.com',
'displayName': 'Test',
'accountId': 'acc123',
'self': 'https://test.atlassian.net/rest/api/2/user',
},
}
result = staging_parser.parse(payload)
async def test_create_jira_view_from_payload_no_user(
self, sample_job_context, sample_user_auth, sample_jira_workspace
):
"""Test factory with no Jira user"""
with pytest.raises(StartingConvoException, match='User not authenticated'):
await JiraFactory.create_jira_view_from_payload(
sample_job_context,
sample_user_auth,
None,
sample_jira_workspace, # type: ignore
)
assert isinstance(result, JiraPayloadSuccess)
assert result.payload.event_type == JiraEventType.LABELED_TICKET
async def test_create_jira_view_from_payload_no_auth(
self, sample_job_context, sample_jira_user, sample_jira_workspace
):
"""Test factory with no SaaS auth"""
with pytest.raises(StartingConvoException, match='User not authenticated'):
await JiraFactory.create_jira_view_from_payload(
sample_job_context,
None,
sample_jira_user,
sample_jira_workspace, # type: ignore
)
def test_parse_prod_label_in_staging_skipped(self, staging_parser):
"""Test prod label is skipped in staging environment."""
payload = {
'webhookEvent': 'jira:issue_updated',
'changelog': {'items': [{'field': 'labels', 'toString': 'openhands'}]},
}
result = staging_parser.parse(payload)
async def test_create_jira_view_from_payload_no_workspace(
self, sample_job_context, sample_user_auth, sample_jira_user
):
"""Test factory with no workspace"""
with pytest.raises(StartingConvoException, match='User not authenticated'):
await JiraFactory.create_jira_view_from_payload(
sample_job_context,
sample_user_auth,
sample_jira_user,
None, # type: ignore
)
class TestJiraViewEdgeCases:
"""Tests for edge cases and error scenarios"""
@patch('integrations.jira.jira_view.create_new_conversation')
@patch('integrations.jira.jira_view.integration_store')
async def test_conversation_creation_with_no_user_secrets(
self,
mock_store,
mock_create_conversation,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when user has no secrets"""
new_conversation_view.saas_user_auth.get_secrets.return_value = None
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result == 'conv-123'
# Verify create_new_conversation was called with custom_secrets=None
call_kwargs = mock_create_conversation.call_args[1]
assert call_kwargs['custom_secrets'] is None
@patch('integrations.jira.jira_view.create_new_conversation')
@patch('integrations.jira.jira_view.integration_store')
async def test_conversation_creation_store_failure(
self,
mock_store,
mock_create_conversation,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when store creation fails"""
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock(side_effect=Exception('Store error'))
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
):
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
def test_new_conversation_view_attributes(self, new_conversation_view):
"""Test new conversation view attribute access"""
assert new_conversation_view.job_context.issue_key == 'TEST-123'
assert new_conversation_view.selected_repo == 'test/repo1'
assert new_conversation_view.conversation_id == 'conv-123'
class TestJiraFactoryIsLabeledTicket:
"""Parameterized tests for JiraFactory.is_labeled_ticket method."""
@pytest.mark.parametrize(
'payload,expected',
[
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {
'items': [{'field': 'labels', 'toString': 'openhands'}]
},
},
True,
id='issue_updated_with_openhands_label',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {
'items': [
{'field': 'labels', 'toString': 'bug'},
{'field': 'labels', 'toString': 'openhands'},
]
},
},
True,
id='issue_updated_with_multiple_labels_including_openhands',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {
'items': [{'field': 'labels', 'toString': 'bug,urgent'}]
},
},
False,
id='issue_updated_without_openhands_label',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {'items': []},
},
False,
id='issue_updated_with_empty_changelog_items',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {},
},
False,
id='issue_updated_with_empty_changelog',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
},
False,
id='issue_updated_without_changelog',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'changelog': {
'items': [{'field': 'labels', 'toString': 'openhands'}]
},
},
False,
id='comment_created_event_with_label',
),
pytest.param(
{
'webhookEvent': 'issue_deleted',
'changelog': {
'items': [{'field': 'labels', 'toString': 'openhands'}]
},
},
False,
id='unsupported_event_type',
),
pytest.param(
{},
False,
id='empty_payload',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {
'items': [{'field': 'status', 'toString': 'In Progress'}]
},
},
False,
id='issue_updated_with_non_label_field',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {
'items': [{'field': 'labels', 'fromString': 'openhands'}]
},
},
False,
id='issue_updated_with_fromString_instead_of_toString',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {
'items': [
{'field': 'labels', 'toString': 'not-openhands'},
{'field': 'priority', 'toString': 'High'},
]
},
},
False,
id='issue_updated_with_mixed_fields_no_openhands',
),
],
)
def test_is_labeled_ticket(self, payload, expected):
"""Test is_labeled_ticket with various payloads."""
with patch('integrations.jira.jira_view.OH_LABEL', 'openhands'):
message = Message(source=SourceType.JIRA, message={'payload': payload})
result = JiraFactory.is_labeled_ticket(message)
assert result == expected
@pytest.mark.parametrize(
'payload,expected',
[
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {
'items': [{'field': 'labels', 'toString': 'openhands-exp'}]
},
},
True,
id='issue_updated_with_staging_label',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'changelog': {
'items': [{'field': 'labels', 'toString': 'openhands'}]
},
},
False,
id='issue_updated_with_prod_label_in_staging_env',
),
],
)
def test_is_labeled_ticket_staging_labels(self, payload, expected):
"""Test is_labeled_ticket with staging environment labels."""
with patch('integrations.jira.jira_view.OH_LABEL', 'openhands-exp'):
message = Message(source=SourceType.JIRA, message={'payload': payload})
result = JiraFactory.is_labeled_ticket(message)
assert result == expected
class TestJiraFactoryIsTicketComment:
"""Parameterized tests for JiraFactory.is_ticket_comment method."""
@pytest.mark.parametrize(
'payload,expected',
[
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Please fix this @openhands'},
},
True,
id='comment_with_openhands_mention',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': '@openhands please help'},
},
True,
id='comment_starting_with_openhands_mention',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Hello @openhands!'},
},
True,
id='comment_with_openhands_mention_and_punctuation',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': '(@openhands)'},
},
True,
id='comment_with_openhands_in_parentheses',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Hey @OpenHands can you help?'},
},
True,
id='comment_with_case_insensitive_mention',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Hey @OPENHANDS!'},
},
True,
id='comment_with_uppercase_mention',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Regular comment without mention'},
},
False,
id='comment_without_mention',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Hello @openhands-agent!'},
},
False,
id='comment_with_openhands_as_prefix',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'user@openhands.com'},
},
False,
id='comment_with_openhands_in_email',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': ''},
},
False,
id='comment_with_empty_body',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {},
},
False,
id='comment_without_body',
),
pytest.param(
{
'webhookEvent': 'comment_created',
},
False,
id='comment_created_without_comment_data',
),
pytest.param(
{
'webhookEvent': 'jira:issue_updated',
'comment': {'body': 'Please fix this @openhands'},
},
False,
id='issue_updated_event_with_mention',
),
pytest.param(
{
'webhookEvent': 'issue_deleted',
'comment': {'body': '@openhands'},
},
False,
id='unsupported_event_type_with_mention',
),
pytest.param(
{},
False,
id='empty_payload',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Multiple @openhands @openhands mentions'},
},
True,
id='comment_with_multiple_mentions',
),
],
)
def test_is_ticket_comment(self, payload, expected):
"""Test is_ticket_comment with various payloads."""
with patch('integrations.jira.jira_view.INLINE_OH_LABEL', '@openhands'), patch(
'integrations.jira.jira_view.has_exact_mention'
) as mock_has_exact_mention:
from integrations.utils import has_exact_mention
mock_has_exact_mention.side_effect = (
lambda text, mention: has_exact_mention(text, mention)
)
message = Message(source=SourceType.JIRA, message={'payload': payload})
result = JiraFactory.is_ticket_comment(message)
assert result == expected
@pytest.mark.parametrize(
'payload,expected',
[
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Please fix this @openhands-exp'},
},
True,
id='comment_with_staging_mention',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': '@openhands-exp please help'},
},
True,
id='comment_starting_with_staging_mention',
),
pytest.param(
{
'webhookEvent': 'comment_created',
'comment': {'body': 'Please fix this @openhands'},
},
False,
id='comment_with_prod_mention_in_staging_env',
),
],
)
def test_is_ticket_comment_staging_labels(self, payload, expected):
"""Test is_ticket_comment with staging environment labels."""
with patch(
'integrations.jira.jira_view.INLINE_OH_LABEL', '@openhands-exp'
), patch(
'integrations.jira.jira_view.has_exact_mention'
) as mock_has_exact_mention:
from integrations.utils import has_exact_mention
mock_has_exact_mention.side_effect = (
lambda text, mention: has_exact_mention(text, mention)
)
message = Message(source=SourceType.JIRA, message={'payload': payload})
result = JiraFactory.is_ticket_comment(message)
assert result == expected
assert isinstance(result, JiraPayloadSkipped)

View File

@@ -149,6 +149,18 @@ def test_infer_repo_from_message():
('https://github.com/My-User/My-Repo.git', ['My-User/My-Repo']),
('Check the my.user/my.repo repository', ['my.user/my.repo']),
('repos: user_1/repo-1 and user.2/repo_2', ['user_1/repo-1', 'user.2/repo_2']),
# Backtick-wrapped repo mentions (common in Slack/Discord messages)
(
'@openhands-exp just echo hello world in `OpenHands/OpenHands-CLI` repository',
['OpenHands/OpenHands-CLI'],
),
(
'@openhands-exp echo hello world with {{OpenHands/OpenHands-CLI}}',
['OpenHands/OpenHands-CLI'],
),
('Deploy the `test/project` repo', ['test/project']),
# Colon-wrapped repo mentions
('Check the :owner/repo: here', ['owner/repo']),
# Large number of repositories
('Repos: a/b, c/d, e/f, g/h, i/j', ['a/b', 'c/d', 'e/f', 'g/h', 'i/j']),
# Mixed with false positives that should be filtered