mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-06 21:44:00 -05:00
feat: allow manual reinstallation for gitlab resolver (#12184)
This commit is contained in:
@@ -80,22 +80,52 @@ class SaaSGitLabService(GitLabService):
|
||||
logger.warning('external_auth_token and user_id not set!')
|
||||
return gitlab_token
|
||||
|
||||
async def get_owned_groups(self) -> list[dict]:
|
||||
async def get_owned_groups(self, min_access_level: int = 40) -> list[dict]:
|
||||
"""
|
||||
Get all groups for which the current user is the owner.
|
||||
Get all top-level groups where the current user has admin access.
|
||||
|
||||
This method supports pagination and fetches all groups where the user has
|
||||
at least the specified access level.
|
||||
|
||||
Args:
|
||||
min_access_level: Minimum access level required (default: 40 for Maintainer or Owner)
|
||||
- 40: Maintainer or Owner
|
||||
- 50: Owner only
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of groups owned by the current user.
|
||||
list[dict]: A list of groups where user has the specified access level or higher.
|
||||
"""
|
||||
url = f'{self.BASE_URL}/groups'
|
||||
params = {'owned': 'true', 'per_page': 100, 'top_level_only': 'true'}
|
||||
groups_with_admin_access = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
try:
|
||||
response, headers = await self._make_request(url, params)
|
||||
return response
|
||||
except Exception:
|
||||
logger.warning('Error fetching owned groups', exc_info=True)
|
||||
return []
|
||||
while True:
|
||||
try:
|
||||
url = f'{self.BASE_URL}/groups'
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'min_access_level': min_access_level,
|
||||
'top_level_only': 'true',
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response:
|
||||
break
|
||||
|
||||
groups_with_admin_access.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
logger.warning(f'Error fetching groups on page {page}', exc_info=True)
|
||||
break
|
||||
|
||||
return groups_with_admin_access
|
||||
|
||||
async def add_owned_projects_and_groups_to_db(self, owned_personal_projects):
|
||||
"""
|
||||
@@ -527,3 +557,55 @@ class SaaSGitLabService(GitLabService):
|
||||
await self._make_request(url=url, params=params, method=RequestMethod.POST)
|
||||
except Exception as e:
|
||||
logger.exception(f'[GitLab]: Reply to MR failed {e}')
|
||||
|
||||
async def get_user_resources_with_admin_access(
|
||||
self,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Get all projects and groups where the current user has admin access (maintainer or owner).
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], list[dict]]: A tuple containing:
|
||||
- list of projects where user has admin access
|
||||
- list of groups where user has admin access
|
||||
"""
|
||||
projects_with_admin_access = []
|
||||
groups_with_admin_access = []
|
||||
|
||||
# Fetch all projects the user is a member of
|
||||
page = 1
|
||||
per_page = 100
|
||||
while True:
|
||||
try:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'membership': 1,
|
||||
'min_access_level': 40, # Maintainer or Owner
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response:
|
||||
break
|
||||
|
||||
projects_with_admin_access.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
logger.warning(f'Error fetching projects on page {page}', exc_info=True)
|
||||
break
|
||||
|
||||
# Fetch all groups where user is owner or maintainer
|
||||
groups_with_admin_access = await self.get_owned_groups(min_access_level=40)
|
||||
|
||||
logger.info(
|
||||
f'Found {len(projects_with_admin_access)} projects and {len(groups_with_admin_access)} groups with admin access'
|
||||
)
|
||||
|
||||
return projects_with_admin_access, groups_with_admin_access
|
||||
|
||||
199
enterprise/integrations/gitlab/webhook_installation.py
Normal file
199
enterprise/integrations/gitlab/webhook_installation.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Shared utilities for GitLab webhook installation.
|
||||
|
||||
This module contains reusable functions and classes for installing GitLab webhooks
|
||||
that can be used by both the cron job and API routes.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import GitService
|
||||
|
||||
# Webhook configuration constants
|
||||
WEBHOOK_NAME = 'OpenHands Resolver'
|
||||
SCOPES: list[str] = [
|
||||
'note_events',
|
||||
'merge_requests_events',
|
||||
'confidential_issues_events',
|
||||
'issues_events',
|
||||
'confidential_note_events',
|
||||
'job_events',
|
||||
'pipeline_events',
|
||||
]
|
||||
|
||||
|
||||
class BreakLoopException(Exception):
|
||||
"""Exception raised when webhook installation conditions are not met or rate limited."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def verify_webhook_conditions(
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
) -> None:
|
||||
"""
|
||||
Verify all conditions are met for webhook installation.
|
||||
Raises BreakLoopException if any condition fails or rate limited.
|
||||
|
||||
Args:
|
||||
gitlab_service: GitLab service instance
|
||||
resource_type: Type of resource (PROJECT or GROUP)
|
||||
resource_id: ID of the resource
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to verify
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
# Check if resource exists
|
||||
does_resource_exist, status = await gitlab_service.check_resource_exists(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does resource exists',
|
||||
extra={
|
||||
'does_resource_exist': does_resource_exist,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
# Check if user has admin access
|
||||
(
|
||||
is_user_admin_of_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Is user admin',
|
||||
extra={
|
||||
'is_user_admin': is_user_admin_of_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if not is_user_admin_of_resource:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
# Check if webhook already exists
|
||||
(
|
||||
does_webhook_exist_on_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_webhook_exists_on_resource(
|
||||
resource_type, resource_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does webhook already exist',
|
||||
extra={
|
||||
'does_webhook_exist_on_resource': does_webhook_exist_on_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if does_webhook_exist_on_resource != webhook.webhook_exists:
|
||||
await webhook_store.update_webhook(
|
||||
webhook, {'webhook_exists': does_webhook_exist_on_resource}
|
||||
)
|
||||
|
||||
if does_webhook_exist_on_resource:
|
||||
raise BreakLoopException()
|
||||
|
||||
|
||||
async def install_webhook_on_resource(
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
) -> tuple[str | None, WebhookStatus | None]:
|
||||
"""
|
||||
Install webhook on a GitLab resource.
|
||||
|
||||
Args:
|
||||
gitlab_service: GitLab service instance
|
||||
resource_type: Type of resource (PROJECT or GROUP)
|
||||
resource_id: ID of the resource
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to install
|
||||
|
||||
Returns:
|
||||
Tuple of (webhook_id, status)
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
|
||||
webhook_uuid = f'{str(uuid4())}'
|
||||
|
||||
webhook_id, status = await gitlab_service.install_webhook(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_name=WEBHOOK_NAME,
|
||||
webhook_url=GITLAB_WEBHOOK_URL,
|
||||
webhook_secret=webhook_secret,
|
||||
webhook_uuid=webhook_uuid,
|
||||
scopes=SCOPES,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Creating new webhook',
|
||||
extra={
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
|
||||
if webhook_id:
|
||||
await webhook_store.update_webhook(
|
||||
webhook=webhook,
|
||||
update_fields={
|
||||
'webhook_secret': webhook_secret,
|
||||
'webhook_exists': True, # webhook was created
|
||||
'webhook_url': GITLAB_WEBHOOK_URL,
|
||||
'scopes': SCOPES,
|
||||
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
|
||||
)
|
||||
|
||||
return webhook_id, status
|
||||
@@ -1,15 +1,28 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from pydantic import BaseModel
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.server.shared import sio
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
gitlab_integration_router = APIRouter(prefix='/integration')
|
||||
webhook_store = GitlabWebhookStore()
|
||||
@@ -18,6 +31,37 @@ token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
|
||||
# Request/Response models
|
||||
class ResourceIdentifier(BaseModel):
|
||||
type: GitLabResourceType
|
||||
id: str
|
||||
|
||||
|
||||
class ReinstallWebhookRequest(BaseModel):
|
||||
resource: ResourceIdentifier
|
||||
|
||||
|
||||
class ResourceWithWebhookStatus(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
full_path: str
|
||||
type: str
|
||||
webhook_installed: bool
|
||||
webhook_uuid: str | None
|
||||
last_synced: str | None
|
||||
|
||||
|
||||
class GitLabResourcesResponse(BaseModel):
|
||||
resources: list[ResourceWithWebhookStatus]
|
||||
|
||||
|
||||
class ResourceInstallationResult(BaseModel):
|
||||
resource_id: str
|
||||
resource_type: str
|
||||
success: bool
|
||||
error: str | None
|
||||
|
||||
|
||||
async def verify_gitlab_signature(
|
||||
header_webhook_secret: str, webhook_uuid: str, user_id: str
|
||||
):
|
||||
@@ -83,3 +127,260 @@ async def gitlab_events(
|
||||
except Exception as e:
|
||||
logger.exception(f'Error processing GitLab event: {e}')
|
||||
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
|
||||
|
||||
|
||||
@gitlab_integration_router.get('/gitlab/resources')
|
||||
async def get_gitlab_resources(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> GitLabResourcesResponse:
|
||||
"""Get all GitLab projects and groups where the user has admin access.
|
||||
|
||||
Returns a list of resources with their webhook installation status.
|
||||
"""
|
||||
try:
|
||||
# Get GitLab service for the user
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only SaaS GitLab service is supported',
|
||||
)
|
||||
|
||||
# Fetch projects and groups with admin access
|
||||
projects, groups = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Filter out projects that belong to a group (nested projects)
|
||||
# We only want top-level personal projects since group webhooks cover nested projects
|
||||
filtered_projects = [
|
||||
project
|
||||
for project in projects
|
||||
if project.get('namespace', {}).get('kind') != 'group'
|
||||
]
|
||||
|
||||
# Extract IDs for bulk fetching
|
||||
project_ids = [str(project['id']) for project in filtered_projects]
|
||||
group_ids = [str(group['id']) for group in groups]
|
||||
|
||||
# Bulk fetch webhook records from database (organization-wide)
|
||||
(
|
||||
project_webhook_map,
|
||||
group_webhook_map,
|
||||
) = await webhook_store.get_webhooks_by_resources(project_ids, group_ids)
|
||||
|
||||
# Parallelize GitLab API calls to check webhook status for all resources
|
||||
async def check_project_webhook(project):
|
||||
project_id = str(project['id'])
|
||||
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
|
||||
GitLabResourceType.PROJECT, project_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
return project_id, webhook_exists
|
||||
|
||||
async def check_group_webhook(group):
|
||||
group_id = str(group['id'])
|
||||
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
|
||||
GitLabResourceType.GROUP, group_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
return group_id, webhook_exists
|
||||
|
||||
# Gather all API calls in parallel
|
||||
project_checks = [
|
||||
check_project_webhook(project) for project in filtered_projects
|
||||
]
|
||||
group_checks = [check_group_webhook(group) for group in groups]
|
||||
|
||||
# Execute all checks concurrently
|
||||
all_results = await asyncio.gather(*(project_checks + group_checks))
|
||||
|
||||
# Split results back into projects and groups
|
||||
num_projects = len(filtered_projects)
|
||||
project_results = all_results[:num_projects]
|
||||
group_results = all_results[num_projects:]
|
||||
|
||||
# Build response
|
||||
resources = []
|
||||
|
||||
# Add projects with their webhook status
|
||||
for project, (project_id, webhook_exists) in zip(
|
||||
filtered_projects, project_results
|
||||
):
|
||||
webhook = project_webhook_map.get(project_id)
|
||||
|
||||
resources.append(
|
||||
ResourceWithWebhookStatus(
|
||||
id=project_id,
|
||||
name=project.get('name', ''),
|
||||
full_path=project.get('path_with_namespace', ''),
|
||||
type='project',
|
||||
webhook_installed=webhook_exists,
|
||||
webhook_uuid=webhook.webhook_uuid if webhook else None,
|
||||
last_synced=(
|
||||
webhook.last_synced.isoformat()
|
||||
if webhook and webhook.last_synced
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Add groups with their webhook status
|
||||
for group, (group_id, webhook_exists) in zip(groups, group_results):
|
||||
webhook = group_webhook_map.get(group_id)
|
||||
|
||||
resources.append(
|
||||
ResourceWithWebhookStatus(
|
||||
id=group_id,
|
||||
name=group.get('name', ''),
|
||||
full_path=group.get('full_path', ''),
|
||||
type='group',
|
||||
webhook_installed=webhook_exists,
|
||||
webhook_uuid=webhook.webhook_uuid if webhook else None,
|
||||
last_synced=(
|
||||
webhook.last_synced.isoformat()
|
||||
if webhook and webhook.last_synced
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Retrieved GitLab resources',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'project_count': len(projects),
|
||||
'group_count': len(groups),
|
||||
},
|
||||
)
|
||||
|
||||
return GitLabResourcesResponse(resources=resources)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error retrieving GitLab resources: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve GitLab resources',
|
||||
)
|
||||
|
||||
|
||||
@gitlab_integration_router.post('/gitlab/reinstall-webhook')
|
||||
async def reinstall_gitlab_webhook(
|
||||
body: ReinstallWebhookRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> ResourceInstallationResult:
|
||||
"""Reinstall GitLab webhook for a specific resource immediately.
|
||||
|
||||
This endpoint validates permissions, resets webhook status in the database,
|
||||
and immediately installs the webhook on the specified resource.
|
||||
"""
|
||||
try:
|
||||
# Get GitLab service for the user
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only SaaS GitLab service is supported',
|
||||
)
|
||||
|
||||
resource_id = body.resource.id
|
||||
resource_type = body.resource.type
|
||||
|
||||
# Check if user has admin access to this resource
|
||||
(
|
||||
has_admin_access,
|
||||
check_status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not has_admin_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User does not have admin access to this resource',
|
||||
)
|
||||
|
||||
# Reset webhook in database (organization-wide, not user-specific)
|
||||
# This allows any admin user to reinstall webhooks
|
||||
await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
|
||||
# Get or create webhook record (without user_id filter)
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not webhook:
|
||||
# Create new webhook record
|
||||
webhook = GitlabWebhook(
|
||||
user_id=user_id, # Track who created it
|
||||
project_id=resource_id
|
||||
if resource_type == GitLabResourceType.PROJECT
|
||||
else None,
|
||||
group_id=resource_id
|
||||
if resource_type == GitLabResourceType.GROUP
|
||||
else None,
|
||||
webhook_exists=False,
|
||||
)
|
||||
await webhook_store.store_webhooks([webhook])
|
||||
# Fetch it again to get the ID (without user_id filter)
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Verify conditions and install webhook
|
||||
try:
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
# Install the webhook
|
||||
webhook_id, install_status = await install_webhook_on_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
if webhook_id:
|
||||
logger.info(
|
||||
'GitLab webhook reinstalled successfully',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'resource_type': resource_type.value,
|
||||
'resource_id': resource_id,
|
||||
},
|
||||
)
|
||||
return ResourceInstallationResult(
|
||||
resource_id=resource_id,
|
||||
resource_type=resource_type.value,
|
||||
success=True,
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to install webhook',
|
||||
)
|
||||
|
||||
except BreakLoopException:
|
||||
# Conditions not met or webhook already exists
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Webhook installation conditions not met or webhook already exists',
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error reinstalling GitLab webhook: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reinstall webhook',
|
||||
)
|
||||
|
||||
@@ -220,6 +220,127 @@ class GitlabWebhookStore:
|
||||
return webhooks[0].webhook_secret
|
||||
return None
|
||||
|
||||
async def get_webhook_by_resource_only(
|
||||
self, resource_type: GitLabResourceType, resource_id: str
|
||||
) -> GitlabWebhook | None:
|
||||
"""Get a webhook by resource without filtering by user_id.
|
||||
|
||||
This allows any admin user in the organization to manage webhooks,
|
||||
not just the original installer.
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource (PROJECT or GROUP)
|
||||
resource_id: The ID of the resource
|
||||
|
||||
Returns:
|
||||
GitlabWebhook object if found, None otherwise
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
if resource_type == GitLabResourceType.PROJECT:
|
||||
query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.project_id == resource_id
|
||||
)
|
||||
else: # GROUP
|
||||
query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.group_id == resource_id
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
webhook = result.scalars().first()
|
||||
return webhook
|
||||
|
||||
async def get_webhooks_by_resources(
|
||||
self, project_ids: list[str], group_ids: list[str]
|
||||
) -> tuple[dict[str, GitlabWebhook], dict[str, GitlabWebhook]]:
|
||||
"""Bulk fetch webhooks for multiple resources.
|
||||
|
||||
This is more efficient than fetching one at a time in a loop.
|
||||
|
||||
Args:
|
||||
project_ids: List of project IDs to fetch
|
||||
group_ids: List of group IDs to fetch
|
||||
|
||||
Returns:
|
||||
Tuple of (project_webhook_map, group_webhook_map)
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
project_webhook_map = {}
|
||||
group_webhook_map = {}
|
||||
|
||||
# Fetch all project webhooks in one query
|
||||
if project_ids:
|
||||
project_query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.project_id.in_(project_ids)
|
||||
)
|
||||
result = await session.execute(project_query)
|
||||
project_webhooks = result.scalars().all()
|
||||
project_webhook_map = {wh.project_id: wh for wh in project_webhooks}
|
||||
|
||||
# Fetch all group webhooks in one query
|
||||
if group_ids:
|
||||
group_query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.group_id.in_(group_ids)
|
||||
)
|
||||
result = await session.execute(group_query)
|
||||
group_webhooks = result.scalars().all()
|
||||
group_webhook_map = {wh.group_id: wh for wh in group_webhooks}
|
||||
|
||||
return project_webhook_map, group_webhook_map
|
||||
|
||||
async def reset_webhook_for_reinstallation_by_resource(
|
||||
self, resource_type: GitLabResourceType, resource_id: str, updating_user_id: str
|
||||
) -> bool:
|
||||
"""Reset webhook for reinstallation without filtering by user_id.
|
||||
|
||||
This allows any admin user to reset webhooks, and updates the user_id
|
||||
to track who last modified it.
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource (PROJECT or GROUP)
|
||||
resource_id: The ID of the resource
|
||||
updating_user_id: The user ID performing the update (for audit purposes)
|
||||
|
||||
Returns:
|
||||
True if webhook was reset, False if not found
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
async with session.begin():
|
||||
if resource_type == GitLabResourceType.PROJECT:
|
||||
update_statement = (
|
||||
update(GitlabWebhook)
|
||||
.where(GitlabWebhook.project_id == resource_id)
|
||||
.values(
|
||||
webhook_exists=False,
|
||||
webhook_uuid=None,
|
||||
user_id=updating_user_id, # Update to track who modified it
|
||||
)
|
||||
)
|
||||
else: # GROUP
|
||||
update_statement = (
|
||||
update(GitlabWebhook)
|
||||
.where(GitlabWebhook.group_id == resource_id)
|
||||
.values(
|
||||
webhook_exists=False,
|
||||
webhook_uuid=None,
|
||||
user_id=updating_user_id, # Update to track who modified it
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(update_statement)
|
||||
rows_updated = result.rowcount
|
||||
|
||||
logger.info(
|
||||
'Reset webhook for reinstallation (organization-wide)',
|
||||
extra={
|
||||
'updating_user_id': updating_user_id,
|
||||
'resource_type': resource_type.value,
|
||||
'resource_id': resource_id,
|
||||
'rows_updated': rows_updated,
|
||||
},
|
||||
)
|
||||
|
||||
return rows_updated > 0
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> GitlabWebhookStore:
|
||||
"""Get an instance of the GitlabWebhookStore.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import asyncio
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from sqlalchemy import text
|
||||
@@ -14,20 +18,6 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.service_types import GitService
|
||||
|
||||
CHUNK_SIZE = 100
|
||||
WEBHOOK_NAME = 'OpenHands Resolver'
|
||||
SCOPES: list[str] = [
|
||||
'note_events',
|
||||
'merge_requests_events',
|
||||
'confidential_issues_events',
|
||||
'issues_events',
|
||||
'confidential_note_events',
|
||||
'job_events',
|
||||
'pipeline_events',
|
||||
]
|
||||
|
||||
|
||||
class BreakLoopException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VerifyWebhookStatus:
|
||||
@@ -43,77 +33,6 @@ class VerifyWebhookStatus:
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_resource_exists(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
"""
|
||||
Check if the GitLab resource still exists
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
does_resource_exist, status = await gitlab_service.check_resource_exists(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does resource exists',
|
||||
extra={
|
||||
'does_resource_exist': does_resource_exist,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_user_has_admin_acccess_to_resource(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
"""
|
||||
Check is user still has permission to resource
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
(
|
||||
is_user_admin_of_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Is user admin',
|
||||
extra={
|
||||
'is_user_admin': is_user_admin_of_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
if not is_user_admin_of_resource:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_webhook_already_exists_on_resource(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
@@ -162,23 +81,8 @@ class VerifyWebhookStatus:
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
await self.check_if_resource_exists(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
await self.check_if_user_has_admin_acccess_to_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
await self.check_if_webhook_already_exists_on_resource(
|
||||
# Use the standalone function
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
@@ -197,51 +101,15 @@ class VerifyWebhookStatus:
|
||||
"""
|
||||
Install webhook on resource
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
|
||||
webhook_uuid = f'{str(uuid4())}'
|
||||
|
||||
webhook_id, status = await gitlab_service.install_webhook(
|
||||
# Use the standalone function
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_name=WEBHOOK_NAME,
|
||||
webhook_url=GITLAB_WEBHOOK_URL,
|
||||
webhook_secret=webhook_secret,
|
||||
webhook_uuid=webhook_uuid,
|
||||
scopes=SCOPES,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Creating new webhook',
|
||||
extra={
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
|
||||
if webhook_id:
|
||||
await webhook_store.update_webhook(
|
||||
webhook=webhook,
|
||||
update_fields={
|
||||
'webhook_secret': webhook_secret,
|
||||
'webhook_exists': True, # webhook was created
|
||||
'webhook_url': GITLAB_WEBHOOK_URL,
|
||||
'scopes': SCOPES,
|
||||
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
|
||||
)
|
||||
|
||||
async def install_webhooks(self):
|
||||
"""
|
||||
Periodically check the conditions for installing a webhook on resource as valid
|
||||
|
||||
204
enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py
Normal file
204
enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Unit tests for SaaSGitLabService."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitlab_service():
|
||||
"""Create a SaaSGitLabService instance for testing."""
|
||||
return SaaSGitLabService(external_auth_id='test_user_id')
|
||||
|
||||
|
||||
class TestGetUserResourcesWithAdminAccess:
|
||||
"""Test cases for get_user_resources_with_admin_access method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_single_page_projects_and_groups(self, gitlab_service):
|
||||
"""Test fetching resources when all data fits in a single page."""
|
||||
# Arrange
|
||||
mock_projects = [
|
||||
{'id': 1, 'name': 'Project 1'},
|
||||
{'id': 2, 'name': 'Project 2'},
|
||||
]
|
||||
mock_groups = [
|
||||
{'id': 10, 'name': 'Group 1'},
|
||||
]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
# First call for projects, second for groups
|
||||
mock_request.side_effect = [
|
||||
(mock_projects, {'Link': ''}), # No next page
|
||||
(mock_groups, {'Link': ''}), # No next page
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 2
|
||||
assert len(groups) == 1
|
||||
assert projects[0]['id'] == 1
|
||||
assert projects[1]['id'] == 2
|
||||
assert groups[0]['id'] == 10
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_multiple_pages_projects(self, gitlab_service):
|
||||
"""Test fetching projects across multiple pages."""
|
||||
# Arrange
|
||||
page1_projects = [{'id': i, 'name': f'Project {i}'} for i in range(1, 101)]
|
||||
page2_projects = [{'id': i, 'name': f'Project {i}'} for i in range(101, 151)]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
(page1_projects, {'Link': '<url>; rel="next"'}), # Has next page
|
||||
(page2_projects, {'Link': ''}), # Last page
|
||||
([], {'Link': ''}), # Groups (empty)
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 150
|
||||
assert len(groups) == 0
|
||||
assert mock_request.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_multiple_pages_groups(self, gitlab_service):
|
||||
"""Test fetching groups across multiple pages."""
|
||||
# Arrange
|
||||
page1_groups = [{'id': i, 'name': f'Group {i}'} for i in range(1, 101)]
|
||||
page2_groups = [{'id': i, 'name': f'Group {i}'} for i in range(101, 151)]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects (empty)
|
||||
(page1_groups, {'Link': '<url>; rel="next"'}), # Has next page
|
||||
(page2_groups, {'Link': ''}), # Last page
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert len(groups) == 150
|
||||
assert mock_request.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_empty_response(self, gitlab_service):
|
||||
"""Test when user has no projects or groups with admin access."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # No projects
|
||||
([], {'Link': ''}), # No groups
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert len(groups) == 0
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_uses_correct_params_for_projects(self, gitlab_service):
|
||||
"""Test that projects API is called with correct parameters."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Check first call (projects)
|
||||
first_call = mock_request.call_args_list[0]
|
||||
assert 'projects' in first_call[0][0]
|
||||
assert first_call[0][1]['membership'] == 1
|
||||
assert first_call[0][1]['min_access_level'] == 40
|
||||
assert first_call[0][1]['per_page'] == '100'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_uses_correct_params_for_groups(self, gitlab_service):
|
||||
"""Test that groups API is called with correct parameters."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Check second call (groups)
|
||||
second_call = mock_request.call_args_list[1]
|
||||
assert 'groups' in second_call[0][0]
|
||||
assert second_call[0][1]['min_access_level'] == 40
|
||||
assert second_call[0][1]['top_level_only'] == 'true'
|
||||
assert second_call[0][1]['per_page'] == '100'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_handles_api_error_gracefully(self, gitlab_service):
|
||||
"""Test that API errors are handled gracefully and don't crash."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
# First call succeeds, second call fails
|
||||
mock_request.side_effect = [
|
||||
([{'id': 1, 'name': 'Project 1'}], {'Link': ''}),
|
||||
Exception('API Error'),
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Should return what was fetched before the error
|
||||
assert len(projects) == 1
|
||||
assert len(groups) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_stops_on_empty_response(self, gitlab_service):
|
||||
"""Test that pagination stops when API returns empty response."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
(None, {'Link': ''}), # Empty response stops pagination
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert mock_request.call_count == 2 # Should not continue pagination
|
||||
@@ -0,0 +1,502 @@
|
||||
"""Unit tests for GitLab integration routes."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, status
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from integrations.gitlab.webhook_installation import BreakLoopException
|
||||
from integrations.types import GitLabResourceType
|
||||
from server.routes.integration.gitlab import (
|
||||
ReinstallWebhookRequest,
|
||||
ResourceIdentifier,
|
||||
get_gitlab_resources,
|
||||
reinstall_gitlab_webhook,
|
||||
)
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitlab_service():
|
||||
"""Create a mock SaaSGitLabService instance."""
|
||||
service = MagicMock(spec=SaaSGitLabService)
|
||||
service.get_user_resources_with_admin_access = AsyncMock(
|
||||
return_value=(
|
||||
[
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'Test Project',
|
||||
'path_with_namespace': 'user/test-project',
|
||||
'namespace': {'kind': 'user'},
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'name': 'Group Project',
|
||||
'path_with_namespace': 'group/group-project',
|
||||
'namespace': {'kind': 'group'},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
'id': 10,
|
||||
'name': 'Test Group',
|
||||
'full_path': 'test-group',
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
service.check_webhook_exists_on_resource = AsyncMock(return_value=(True, None))
|
||||
service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_webhook():
|
||||
"""Create a mock webhook object."""
|
||||
webhook = MagicMock(spec=GitlabWebhook)
|
||||
webhook.webhook_uuid = 'test-uuid'
|
||||
webhook.last_synced = None
|
||||
return webhook
|
||||
|
||||
|
||||
class TestGetGitLabResources:
|
||||
"""Test cases for get_gitlab_resources endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_success(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test successfully retrieving GitLab resources with webhook status."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(
|
||||
return_value=({}, {}) # Empty maps for simplicity
|
||||
)
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert len(response.resources) == 2 # 1 project (filtered) + 1 group
|
||||
assert response.resources[0].type == 'project'
|
||||
assert response.resources[0].id == '1'
|
||||
assert response.resources[0].name == 'Test Project'
|
||||
assert response.resources[1].type == 'group'
|
||||
assert response.resources[1].id == '10'
|
||||
mock_gitlab_service.get_user_resources_with_admin_access.assert_called_once()
|
||||
mock_webhook_store.get_webhooks_by_resources.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_filters_nested_projects(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test that projects nested under groups are filtered out."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(return_value=({}, {}))
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
# Should only include the user project, not the group project
|
||||
project_resources = [r for r in response.resources if r.type == 'project']
|
||||
assert len(project_resources) == 1
|
||||
assert project_resources[0].id == '1'
|
||||
assert project_resources[0].name == 'Test Project'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_includes_webhook_metadata(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test that webhook metadata is included in the response."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(
|
||||
return_value=({'1': mock_webhook}, {'10': mock_webhook})
|
||||
)
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert response.resources[0].webhook_uuid == 'test-uuid'
|
||||
assert response.resources[1].webhook_uuid == 'test-uuid'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
async def test_get_resources_non_saas_service(
|
||||
self, mock_gitlab_service_impl, mock_gitlab_service
|
||||
):
|
||||
"""Test that non-SaaS GitLab service raises an error."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
non_saas_service = AsyncMock()
|
||||
mock_gitlab_service_impl.return_value = non_saas_service
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'Only SaaS GitLab service is supported' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_parallel_api_calls(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test that webhook status checks are made in parallel."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(return_value=({}, {}))
|
||||
call_count = 0
|
||||
|
||||
async def track_calls(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return (True, None)
|
||||
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
side_effect=track_calls
|
||||
)
|
||||
|
||||
# Act
|
||||
await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
# Should be called for each resource (1 project + 1 group)
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
class TestReinstallGitLabWebhook:
|
||||
"""Test cases for reinstall_gitlab_webhook endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_success_existing_webhook(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test successful webhook reinstallation when webhook record exists."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-123'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-123', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.resource_id == resource_id
|
||||
assert result.resource_type == resource_type.value
|
||||
assert result.error is None
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource.assert_called_once_with(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
mock_verify_conditions.assert_called_once()
|
||||
mock_install_webhook.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_success_new_webhook_record(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test successful webhook reinstallation when webhook record doesn't exist."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-456'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = (
|
||||
AsyncMock(return_value=False) # No existing webhook to reset
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
side_effect=[
|
||||
None,
|
||||
MagicMock(),
|
||||
] # First call returns None, second returns new webhook
|
||||
)
|
||||
mock_webhook_store.store_webhooks = AsyncMock()
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-456', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
mock_webhook_store.store_webhooks.assert_called_once()
|
||||
# Should fetch webhook twice: once to check, once after creating
|
||||
assert mock_webhook_store.get_webhook_by_resource_only.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_no_admin_access(
|
||||
self, mock_isinstance, mock_gitlab_service_impl, mock_gitlab_service
|
||||
):
|
||||
"""Test reinstallation when user doesn't have admin access."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-789'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'does not have admin access' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
async def test_reinstall_webhook_non_saas_service(self, mock_gitlab_service_impl):
|
||||
"""Test reinstallation with non-SaaS GitLab service."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-999'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
non_saas_service = AsyncMock()
|
||||
mock_gitlab_service_impl.return_value = non_saas_service
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'Only SaaS GitLab service is supported' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_conditions_not_met(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation when webhook conditions are not met."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-111'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.side_effect = BreakLoopException()
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'conditions not met' in exc_info.value.detail.lower()
|
||||
mock_install_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_installation_fails(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation when webhook installation fails."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-222'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = (None, None) # Installation failed
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert 'Failed to install webhook' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_group_resource(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation for a group resource."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'group-333'
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-group', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.resource_id == resource_id
|
||||
assert result.resource_type == resource_type.value
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource.assert_called_once_with(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
388
enterprise/tests/unit/storage/test_gitlab_webhook_store.py
Normal file
388
enterprise/tests/unit/storage/test_gitlab_webhook_store.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""Unit tests for GitlabWebhookStore."""
|
||||
|
||||
import pytest
|
||||
from integrations.types import GitLabResourceType
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def webhook_store(async_session_maker):
|
||||
"""Create a GitlabWebhookStore instance for testing."""
|
||||
return GitlabWebhookStore(a_session_maker=async_session_maker)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_webhooks(async_session_maker):
|
||||
"""Create sample webhook records for testing."""
|
||||
async with async_session_maker() as session:
|
||||
# Create webhooks for user_1
|
||||
webhook1 = GitlabWebhook(
|
||||
project_id='project-1',
|
||||
group_id=None,
|
||||
user_id='user_1',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-1',
|
||||
webhook_uuid='uuid-1',
|
||||
)
|
||||
webhook2 = GitlabWebhook(
|
||||
project_id='project-2',
|
||||
group_id=None,
|
||||
user_id='user_1',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-2',
|
||||
webhook_uuid='uuid-2',
|
||||
)
|
||||
webhook3 = GitlabWebhook(
|
||||
project_id=None,
|
||||
group_id='group-1',
|
||||
user_id='user_1',
|
||||
webhook_exists=False, # Already marked for reinstallation
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-3',
|
||||
webhook_uuid='uuid-3',
|
||||
)
|
||||
|
||||
# Create webhook for user_2
|
||||
webhook4 = GitlabWebhook(
|
||||
project_id='project-3',
|
||||
group_id=None,
|
||||
user_id='user_2',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-4',
|
||||
webhook_uuid='uuid-4',
|
||||
)
|
||||
|
||||
session.add_all([webhook1, webhook2, webhook3, webhook4])
|
||||
await session.commit()
|
||||
|
||||
# Refresh to get IDs (outside of begin() context)
|
||||
await session.refresh(webhook1)
|
||||
await session.refresh(webhook2)
|
||||
await session.refresh(webhook3)
|
||||
await session.refresh(webhook4)
|
||||
|
||||
return [webhook1, webhook2, webhook3, webhook4]
|
||||
|
||||
|
||||
class TestGetWebhookByResourceOnly:
|
||||
"""Test cases for get_webhook_by_resource_only method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_webhook_by_resource_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test getting a project webhook by resource ID without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-1'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.project_id == resource_id
|
||||
assert webhook.user_id == 'user_1'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_webhook_by_resource_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test getting a group webhook by resource ID without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-1'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.group_id == resource_id
|
||||
assert webhook.user_id == 'user_1'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook_by_resource_only_not_found(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test getting a webhook that doesn't exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'non-existent-project'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook_by_resource_only_organization_wide(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test that webhook lookup works regardless of which user originally created it."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-3' # Created by user_2
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.project_id == resource_id
|
||||
# Should find webhook even though it was created by a different user
|
||||
assert webhook.user_id == 'user_2'
|
||||
|
||||
|
||||
class TestResetWebhookForReinstallationByResource:
|
||||
"""Test cases for reset_webhook_for_reinstallation_by_resource method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_project_webhook_by_resource(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test resetting a project webhook by resource without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-1'
|
||||
updating_user_id = 'user_2' # Different user can reset it
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.project_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.webhook_uuid is None
|
||||
assert (
|
||||
webhook.user_id == updating_user_id
|
||||
) # Updated to track who modified it
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_group_webhook_by_resource(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test resetting a group webhook by resource without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-1'
|
||||
updating_user_id = 'user_2'
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.group_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.webhook_uuid is None
|
||||
assert webhook.user_id == updating_user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_webhook_by_resource_not_found(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test resetting a webhook that doesn't exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'non-existent-project'
|
||||
updating_user_id = 'user_1'
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_webhook_by_resource_organization_wide(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test that any user can reset a webhook regardless of original creator."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-3' # Created by user_2
|
||||
updating_user_id = 'user_1' # Different user resetting it
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset and user_id updated
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.project_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.user_id == updating_user_id
|
||||
|
||||
|
||||
class TestGetWebhooksByResources:
|
||||
"""Test cases for get_webhooks_by_resources method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_projects_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for multiple projects."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'project-2', 'project-3']
|
||||
group_ids: list[str] = []
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 3
|
||||
assert 'project-1' in project_map
|
||||
assert 'project-2' in project_map
|
||||
assert 'project-3' in project_map
|
||||
assert len(group_map) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_groups_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for multiple groups."""
|
||||
# Arrange
|
||||
project_ids: list[str] = []
|
||||
group_ids = ['group-1']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 0
|
||||
assert len(group_map) == 1
|
||||
assert 'group-1' in group_map
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_mixed(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for both projects and groups."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'project-2']
|
||||
group_ids = ['group-1']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 2
|
||||
assert len(group_map) == 1
|
||||
assert 'project-1' in project_map
|
||||
assert 'project-2' in project_map
|
||||
assert 'group-1' in group_map
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_empty_lists(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test bulk fetching with empty ID lists."""
|
||||
# Arrange
|
||||
project_ids: list[str] = []
|
||||
group_ids: list[str] = []
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 0
|
||||
assert len(group_map) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_partial_matches(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching when some IDs don't exist."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'non-existent-project']
|
||||
group_ids = ['group-1', 'non-existent-group']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 1
|
||||
assert 'project-1' in project_map
|
||||
assert 'non-existent-project' not in project_map
|
||||
assert len(group_map) == 1
|
||||
assert 'group-1' in group_map
|
||||
assert 'non-existent-group' not in group_map
|
||||
438
enterprise/tests/unit/sync/test_install_gitlab_webhooks.py
Normal file
438
enterprise/tests/unit/sync/test_install_gitlab_webhooks.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Unit tests for install_gitlab_webhooks module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitlab_service():
|
||||
"""Create a mock GitLab service."""
|
||||
service = MagicMock()
|
||||
service.check_resource_exists = AsyncMock(return_value=(True, None))
|
||||
service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
service.check_webhook_exists_on_resource = AsyncMock(return_value=(False, None))
|
||||
service.install_webhook = AsyncMock(return_value=('webhook-id-123', None))
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_webhook_store():
|
||||
"""Create a mock webhook store."""
|
||||
store = MagicMock()
|
||||
store.delete_webhook = AsyncMock()
|
||||
store.update_webhook = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_webhook():
|
||||
"""Create a sample webhook object."""
|
||||
webhook = MagicMock(spec=GitlabWebhook)
|
||||
webhook.user_id = 'test_user_id'
|
||||
webhook.webhook_exists = False
|
||||
webhook.webhook_uuid = None
|
||||
return webhook
|
||||
|
||||
|
||||
class TestVerifyWebhookConditions:
|
||||
"""Test cases for verify_webhook_conditions function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_all_pass(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when all conditions are met for webhook installation."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
# Should not raise any exception
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_gitlab_service.check_resource_exists.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_gitlab_service.check_webhook_exists_on_resource.assert_called_once_with(
|
||||
resource_type, resource_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_resource_does_not_exist(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when resource does not exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-999'
|
||||
mock_gitlab_service.check_resource_exists = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook is deleted
|
||||
mock_webhook_store.delete_webhook.assert_called_once_with(sample_webhook)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_resource_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during resource existence check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_resource_exists = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not delete webhook on rate limit
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_user_no_admin_access(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when user does not have admin access."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-456'
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook is deleted
|
||||
mock_webhook_store.delete_webhook.assert_called_once_with(sample_webhook)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_admin_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during admin access check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not delete webhook on rate limit
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_webhook_already_exists(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when webhook already exists on resource."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_webhook_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during webhook existence check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_updates_webhook_status_mismatch(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that webhook status is updated when database and API don't match."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
sample_webhook.webhook_exists = True # DB says exists
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(False, None) # API says doesn't exist
|
||||
)
|
||||
|
||||
# Act
|
||||
# Should not raise BreakLoopException when webhook doesn't exist (allows installation)
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook status was updated to match API
|
||||
mock_webhook_store.update_webhook.assert_called_once_with(
|
||||
sample_webhook, {'webhook_exists': False}
|
||||
)
|
||||
|
||||
|
||||
class TestInstallWebhookOnResource:
|
||||
"""Test cases for install_webhook_on_resource function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_success(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test successful webhook installation."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id == 'webhook-id-123'
|
||||
assert status is None
|
||||
mock_gitlab_service.install_webhook.assert_called_once()
|
||||
mock_webhook_store.update_webhook.assert_called_once()
|
||||
# Verify update_webhook was called with correct fields (using keyword arguments)
|
||||
call_args = mock_webhook_store.update_webhook.call_args
|
||||
assert call_args[1]['webhook'] == sample_webhook
|
||||
update_fields = call_args[1]['update_fields']
|
||||
assert update_fields['webhook_exists'] is True
|
||||
assert update_fields['webhook_url'] == GITLAB_WEBHOOK_URL
|
||||
assert 'webhook_secret' in update_fields
|
||||
assert 'webhook_uuid' in update_fields
|
||||
assert 'scopes' in update_fields
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_group_resource(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test webhook installation for a group resource."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-456'
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id == 'webhook-id-123'
|
||||
# Verify install_webhook was called with GROUP type
|
||||
call_args = mock_gitlab_service.install_webhook.call_args
|
||||
assert call_args[1]['resource_type'] == resource_type
|
||||
assert call_args[1]['resource_id'] == resource_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_rate_limited(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when installation is rate limited."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.install_webhook = AsyncMock(
|
||||
return_value=(None, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not update webhook on rate limit
|
||||
mock_webhook_store.update_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_installation_fails(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when webhook installation fails."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.install_webhook = AsyncMock(return_value=(None, None))
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id is None
|
||||
assert status is None
|
||||
# Should not update webhook when installation fails
|
||||
mock_webhook_store.update_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_generates_unique_secrets(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that unique webhook secrets and UUIDs are generated."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act - First call
|
||||
webhook_id1, _ = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Capture first call's values before resetting
|
||||
call1_secret = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_secret']
|
||||
call1_uuid = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_uuid']
|
||||
|
||||
# Reset mocks and call again
|
||||
mock_gitlab_service.install_webhook.reset_mock()
|
||||
mock_webhook_store.update_webhook.reset_mock()
|
||||
|
||||
# Act - Second call
|
||||
webhook_id2, _ = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Capture second call's values
|
||||
call2_secret = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_secret']
|
||||
call2_uuid = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_uuid']
|
||||
|
||||
# Assert - Secrets and UUIDs should be different
|
||||
assert call1_secret != call2_secret
|
||||
assert call1_uuid != call2_uuid
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_uses_correct_webhook_name_and_url(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that correct webhook name and URL are used."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_gitlab_service.install_webhook.call_args
|
||||
assert call_args[1]['webhook_name'] == 'OpenHands Resolver'
|
||||
assert call_args[1]['webhook_url'] == GITLAB_WEBHOOK_URL
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GitLabWebhookManagerState } from "#/components/features/settings/git-settings/gitlab-webhook-manager-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("GitLabWebhookManagerState", () => {
|
||||
it("should render title and message with translated keys", () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
titleKey: I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE,
|
||||
messageKey: I18nKey.GITLAB$WEBHOOK_MANAGER_LOADING,
|
||||
};
|
||||
|
||||
// Act
|
||||
render(<GitLabWebhookManagerState {...props} />);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText(I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(I18nKey.GITLAB$WEBHOOK_MANAGER_LOADING),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply custom className to container", () => {
|
||||
// Arrange
|
||||
const customClassName = "custom-container-class";
|
||||
const props = {
|
||||
titleKey: I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE,
|
||||
messageKey: I18nKey.GITLAB$WEBHOOK_MANAGER_LOADING,
|
||||
className: customClassName,
|
||||
};
|
||||
|
||||
// Act
|
||||
const { container } = render(<GitLabWebhookManagerState {...props} />);
|
||||
|
||||
// Assert
|
||||
const containerElement = container.firstChild as HTMLElement;
|
||||
expect(containerElement).toHaveClass(customClassName);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { GitLabWebhookManager } from "#/components/features/settings/git-settings/gitlab-webhook-manager";
|
||||
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||
import type {
|
||||
GitLabResource,
|
||||
ResourceInstallationResult,
|
||||
} from "#/api/integration-service/integration-service.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast handlers
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockResources: GitLabResource[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "Test Group",
|
||||
full_path: "test-group",
|
||||
type: "group",
|
||||
webhook_installed: true,
|
||||
webhook_uuid: "uuid-123",
|
||||
last_synced: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
describe("GitLabWebhookManager", () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderComponent = () => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitLabWebhookManager />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("should display loading state when fetching resources", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_MANAGER_LOADING"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error state when fetching fails", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockRejectedValue(
|
||||
new Error("Failed to fetch"),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_MANAGER_ERROR"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display no resources message when list is empty", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_MANAGER_NO_RESOURCES"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display resources table when resources are available", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: mockResources,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("user/test-project")).toBeInTheDocument();
|
||||
expect(screen.getByText("test-group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display correct resource types in table", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: mockResources,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const projectType = screen.getByText("project");
|
||||
const groupType = screen.getByText("group");
|
||||
expect(projectType).toBeInTheDocument();
|
||||
expect(groupType).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable reinstall button when webhook is already installed", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "10",
|
||||
name: "Test Group",
|
||||
full_path: "test-group",
|
||||
type: "group",
|
||||
webhook_installed: true,
|
||||
webhook_uuid: "uuid-123",
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const reinstallButton = screen.getByTestId(
|
||||
"reinstall-webhook-button-group:10",
|
||||
);
|
||||
expect(reinstallButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should enable reinstall button when webhook is not installed", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const reinstallButton = screen.getByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
expect(reinstallButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call reinstall service when reinstall button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const reinstallSpy = vi.spyOn(integrationService, "reinstallGitLabWebhook");
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(reinstallSpy).toHaveBeenCalledWith({
|
||||
resource: {
|
||||
type: "project",
|
||||
id: "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state on button during reinstallation", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
let resolveReinstall: (value: ResourceInstallationResult) => void;
|
||||
const reinstallPromise = new Promise<ResourceInstallationResult>(
|
||||
(resolve) => {
|
||||
resolveReinstall = resolve;
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(integrationService, "reinstallGitLabWebhook").mockReturnValue(
|
||||
reinstallPromise,
|
||||
);
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_REINSTALLING"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
resolveReinstall!({
|
||||
resource_id: "1",
|
||||
resource_type: "project",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message when reinstallation fails", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const errorMessage = "Permission denied";
|
||||
vi.spyOn(integrationService, "reinstallGitLabWebhook").mockResolvedValue({
|
||||
resource_id: "1",
|
||||
resource_type: "project",
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display success toast when reinstallation succeeds", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
vi.spyOn(integrationService, "reinstallGitLabWebhook").mockResolvedValue({
|
||||
resource_id: "1",
|
||||
resource_type: "project",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
|
||||
"GITLAB$WEBHOOK_REINSTALL_SUCCESS",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error toast when reinstallation throws error", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
const errorMessage = "Network error";
|
||||
|
||||
vi.spyOn(integrationService, "reinstallGitLabWebhook").mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(displayErrorToastSpy).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WebhookStatusBadge } from "#/components/features/settings/git-settings/webhook-status-badge";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("WebhookStatusBadge", () => {
|
||||
it("should display installed status when webhook is installed", () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
webhookInstalled: true,
|
||||
};
|
||||
|
||||
// Act
|
||||
render(<WebhookStatusBadge {...props} />);
|
||||
|
||||
// Assert
|
||||
const badge = screen.getByText("GITLAB$WEBHOOK_STATUS_INSTALLED");
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display not installed status when webhook is not installed", () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
webhookInstalled: false,
|
||||
};
|
||||
|
||||
// Act
|
||||
render(<WebhookStatusBadge {...props} />);
|
||||
|
||||
// Assert
|
||||
const badge = screen.getByText("GITLAB$WEBHOOK_STATUS_NOT_INSTALLED");
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display installed status when installation result is successful", () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
webhookInstalled: false,
|
||||
installationResult: {
|
||||
success: true,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
render(<WebhookStatusBadge {...props} />);
|
||||
|
||||
// Assert
|
||||
const badge = screen.getByText("GITLAB$WEBHOOK_STATUS_INSTALLED");
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display failed status when installation result has error", () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
webhookInstalled: false,
|
||||
installationResult: {
|
||||
success: false,
|
||||
error: "Installation failed",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
render(<WebhookStatusBadge {...props} />);
|
||||
|
||||
// Assert
|
||||
const badge = screen.getByText("GITLAB$WEBHOOK_STATUS_FAILED");
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error message when installation fails", () => {
|
||||
// Arrange
|
||||
const errorMessage = "Permission denied";
|
||||
const props = {
|
||||
webhookInstalled: false,
|
||||
installationResult: {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
render(<WebhookStatusBadge {...props} />);
|
||||
|
||||
// Assert
|
||||
const badgeContainer = screen.getByText(
|
||||
"GITLAB$WEBHOOK_STATUS_FAILED",
|
||||
).parentElement;
|
||||
expect(badgeContainer).toHaveAttribute("title", errorMessage);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||
|
||||
const VALID_OSS_CONFIG: GetConfigResponse = {
|
||||
APP_MODE: "oss",
|
||||
@@ -63,6 +64,15 @@ const renderGitSettingsScreen = () => {
|
||||
GITLAB$HOST_LABEL: "GitLab Host",
|
||||
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
|
||||
BITBUCKET$HOST_LABEL: "Bitbucket Host",
|
||||
SETTINGS$GITLAB: "GitLab",
|
||||
COMMON$STATUS: "Status",
|
||||
STATUS$CONNECTED: "Connected",
|
||||
SETTINGS$GITLAB_NOT_CONNECTED: "Not Connected",
|
||||
SETTINGS$GITLAB_REINSTALL_WEBHOOK: "Reinstall Webhook",
|
||||
SETTINGS$GITLAB_INSTALLING_WEBHOOK:
|
||||
"Installing GitLab webhook, please wait a few minutes.",
|
||||
SETTINGS$SAVING: "Saving...",
|
||||
ERROR$GENERIC: "An error occurred",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -356,7 +366,9 @@ describe("Form submission", () => {
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const azureDevOpsInput = await screen.findByTestId("azure-devops-token-input");
|
||||
const azureDevOpsInput = await screen.findByTestId(
|
||||
"azure-devops-token-input",
|
||||
);
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(azureDevOpsInput, "test-token");
|
||||
@@ -560,3 +572,101 @@ describe("Status toasts", () => {
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitLab Webhook Manager Integration", () => {
|
||||
it("should not render GitLab webhook manager in OSS mode", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render GitLab webhook manager in SaaS mode without APP_SLUG", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render GitLab webhook manager when token is not set", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
APP_SLUG: "test-slug",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render GitLab webhook manager when token is set", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getResourcesSpy = vi.spyOn(
|
||||
integrationService,
|
||||
"getGitLabResources",
|
||||
);
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
APP_SLUG: "test-slug",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
getResourcesSpy.mockResolvedValue({
|
||||
resources: [],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
expect(getResourcesSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import {
|
||||
GitLabResourcesResponse,
|
||||
ReinstallWebhookRequest,
|
||||
ResourceIdentifier,
|
||||
ResourceInstallationResult,
|
||||
} from "./integration-service.types";
|
||||
|
||||
export const integrationService = {
|
||||
/**
|
||||
* Get all GitLab projects and groups where the user has admin access
|
||||
* @returns Promise with list of resources and their webhook status
|
||||
*/
|
||||
getGitLabResources: async (): Promise<GitLabResourcesResponse> => {
|
||||
const { data } = await openHands.get<GitLabResourcesResponse>(
|
||||
"/integration/gitlab/resources",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reinstall webhook on a specific GitLab resource
|
||||
* @param resource - Resource to reinstall webhook on
|
||||
* @returns Promise with installation result
|
||||
*/
|
||||
reinstallGitLabWebhook: async ({
|
||||
resource,
|
||||
}: {
|
||||
resource: ResourceIdentifier;
|
||||
}): Promise<ResourceInstallationResult> => {
|
||||
const requestBody: ReinstallWebhookRequest = { resource };
|
||||
const { data } = await openHands.post<ResourceInstallationResult>(
|
||||
"/integration/gitlab/reinstall-webhook",
|
||||
requestBody,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface GitLabResource {
|
||||
id: string;
|
||||
name: string;
|
||||
full_path: string;
|
||||
type: "project" | "group";
|
||||
webhook_installed: boolean;
|
||||
webhook_uuid: string | null;
|
||||
last_synced: string | null;
|
||||
}
|
||||
|
||||
export interface GitLabResourcesResponse {
|
||||
resources: GitLabResource[];
|
||||
}
|
||||
|
||||
export interface ResourceIdentifier {
|
||||
type: "project" | "group";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ReinstallWebhookRequest {
|
||||
resource: ResourceIdentifier;
|
||||
}
|
||||
|
||||
export interface ResourceInstallationResult {
|
||||
resource_id: string;
|
||||
resource_type: string;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface GitLabWebhookManagerStateProps {
|
||||
className?: string;
|
||||
titleKey: I18nKey;
|
||||
messageKey: I18nKey;
|
||||
messageColor?: string;
|
||||
}
|
||||
|
||||
export function GitLabWebhookManagerState({
|
||||
className,
|
||||
titleKey,
|
||||
messageKey,
|
||||
messageColor = "text-gray-400",
|
||||
}: GitLabWebhookManagerStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
<Typography.H3 className="text-lg font-medium text-white">
|
||||
{t(titleKey)}
|
||||
</Typography.H3>
|
||||
<Typography.Text className={cn("text-sm", messageColor)}>
|
||||
{t(messageKey)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useGitLabResources } from "#/hooks/query/use-gitlab-resources-list";
|
||||
import { useReinstallGitLabWebhook } from "#/hooks/mutation/use-reinstall-gitlab-webhook";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import type { GitLabResource } from "#/api/integration-service/integration-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { WebhookStatusBadge } from "./webhook-status-badge";
|
||||
import { GitLabWebhookManagerState } from "./gitlab-webhook-manager-state";
|
||||
|
||||
interface GitLabWebhookManagerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitLabWebhookManager({ className }: GitLabWebhookManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [installingResource, setInstallingResource] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [installationResults, setInstallationResults] = useState<
|
||||
Map<string, { success: boolean; error: string | null }>
|
||||
>(new Map());
|
||||
|
||||
const { data, isLoading, isError } = useGitLabResources(true);
|
||||
const reinstallMutation = useReinstallGitLabWebhook();
|
||||
|
||||
const resources = data?.resources || [];
|
||||
|
||||
const handleReinstall = async (resource: GitLabResource) => {
|
||||
const key = `${resource.type}:${resource.id}`;
|
||||
setInstallingResource(key);
|
||||
|
||||
// Clear previous result for this resource
|
||||
const newResults = new Map(installationResults);
|
||||
newResults.delete(key);
|
||||
setInstallationResults(newResults);
|
||||
|
||||
try {
|
||||
const result = await reinstallMutation.mutateAsync({
|
||||
type: resource.type,
|
||||
id: resource.id,
|
||||
});
|
||||
|
||||
// Store result for display
|
||||
const resultsMap = new Map(installationResults);
|
||||
resultsMap.set(key, {
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
});
|
||||
setInstallationResults(resultsMap);
|
||||
} catch (error: unknown) {
|
||||
// Store error result
|
||||
const resultsMap = new Map(installationResults);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t(I18nKey.GITLAB$WEBHOOK_REINSTALL_FAILED);
|
||||
resultsMap.set(key, {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
setInstallationResults(resultsMap);
|
||||
} finally {
|
||||
setInstallingResource(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getResourceKey = (resource: GitLabResource) =>
|
||||
`${resource.type}:${resource.id}`;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<GitLabWebhookManagerState
|
||||
className={className}
|
||||
titleKey={I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE}
|
||||
messageKey={I18nKey.GITLAB$WEBHOOK_MANAGER_LOADING}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<GitLabWebhookManagerState
|
||||
className={className}
|
||||
titleKey={I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE}
|
||||
messageKey={I18nKey.GITLAB$WEBHOOK_MANAGER_ERROR}
|
||||
messageColor="text-red-400"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (resources.length === 0) {
|
||||
return (
|
||||
<GitLabWebhookManagerState
|
||||
className={className}
|
||||
titleKey={I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE}
|
||||
messageKey={I18nKey.GITLAB$WEBHOOK_MANAGER_NO_RESOURCES}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.H3 className="text-lg font-medium text-white">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE)}
|
||||
</Typography.H3>
|
||||
</div>
|
||||
|
||||
<Typography.Text className="text-sm text-gray-400">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_MANAGER_DESCRIPTION)}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="border border-neutral-700 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_COLUMN_RESOURCE)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_COLUMN_TYPE)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_COLUMN_STATUS)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_COLUMN_ACTION)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-700">
|
||||
{resources.map((resource) => {
|
||||
const key = getResourceKey(resource);
|
||||
const result = installationResults.get(key);
|
||||
const isInstalling = installingResource === key;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
className="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<Typography.Text className="text-sm font-medium text-white">
|
||||
{resource.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-xs text-gray-400">
|
||||
{resource.full_path}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Typography.Text className="text-sm text-gray-300 capitalize">
|
||||
{resource.type}
|
||||
</Typography.Text>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<WebhookStatusBadge
|
||||
webhookInstalled={resource.webhook_installed}
|
||||
installationResult={result}
|
||||
/>
|
||||
{result?.error && (
|
||||
<Typography.Text className="text-xs text-red-400">
|
||||
{result.error}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => handleReinstall(resource)}
|
||||
isDisabled={
|
||||
installingResource !== null ||
|
||||
resource.webhook_installed ||
|
||||
result?.success === true
|
||||
}
|
||||
className="cursor-pointer"
|
||||
testId={`reinstall-webhook-button-${key}`}
|
||||
>
|
||||
{isInstalling
|
||||
? t(I18nKey.GITLAB$WEBHOOK_REINSTALLING)
|
||||
: t(I18nKey.GITLAB$WEBHOOK_REINSTALL)}
|
||||
</BrandButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
export interface WebhookStatusBadgeProps {
|
||||
webhookInstalled: boolean;
|
||||
installationResult?: { success: boolean; error: string | null } | null;
|
||||
}
|
||||
|
||||
export function WebhookStatusBadge({
|
||||
webhookInstalled,
|
||||
installationResult,
|
||||
}: WebhookStatusBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (installationResult) {
|
||||
if (installationResult.success) {
|
||||
return (
|
||||
<Typography.Text className="px-2 py-1 text-xs rounded bg-green-500/20 text-green-400">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_STATUS_INSTALLED)}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span title={installationResult.error || undefined}>
|
||||
<Typography.Text className="px-2 py-1 text-xs rounded bg-red-500/20 text-red-400">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_STATUS_FAILED)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (webhookInstalled) {
|
||||
return (
|
||||
<Typography.Text className="px-2 py-1 text-xs rounded bg-green-500/20 text-green-400">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_STATUS_INSTALLED)}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Text className="px-2 py-1 text-xs rounded bg-gray-500/20 text-gray-400">
|
||||
{t(I18nKey.GITLAB$WEBHOOK_STATUS_NOT_INSTALLED)}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
47
frontend/src/hooks/mutation/use-reinstall-gitlab-webhook.ts
Normal file
47
frontend/src/hooks/mutation/use-reinstall-gitlab-webhook.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||
import type {
|
||||
ResourceIdentifier,
|
||||
ResourceInstallationResult,
|
||||
} from "#/api/integration-service/integration-service.types";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
|
||||
/**
|
||||
* Hook to reinstall webhook on a specific resource
|
||||
*/
|
||||
export function useReinstallGitLabWebhook() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
ResourceInstallationResult,
|
||||
Error,
|
||||
ResourceIdentifier,
|
||||
unknown
|
||||
>({
|
||||
mutationFn: (resource: ResourceIdentifier) =>
|
||||
integrationService.reinstallGitLabWebhook({ resource }),
|
||||
onSuccess: (data) => {
|
||||
// Invalidate and refetch the resources list
|
||||
queryClient.invalidateQueries({ queryKey: ["gitlab-resources"] });
|
||||
|
||||
if (data.success) {
|
||||
displaySuccessToast(t(I18nKey.GITLAB$WEBHOOK_REINSTALL_SUCCESS));
|
||||
} else if (data.error) {
|
||||
displayErrorToast(data.error);
|
||||
} else {
|
||||
displayErrorToast(t(I18nKey.GITLAB$WEBHOOK_REINSTALL_FAILED));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage =
|
||||
error?.message || t(I18nKey.GITLAB$WEBHOOK_REINSTALL_FAILED);
|
||||
displayErrorToast(errorMessage);
|
||||
},
|
||||
});
|
||||
}
|
||||
16
frontend/src/hooks/query/use-gitlab-resources-list.ts
Normal file
16
frontend/src/hooks/query/use-gitlab-resources-list.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||
import type { GitLabResourcesResponse } from "#/api/integration-service/integration-service.types";
|
||||
|
||||
/**
|
||||
* Hook to fetch GitLab resources with webhook status
|
||||
*/
|
||||
export function useGitLabResources(enabled: boolean = true) {
|
||||
return useQuery<GitLabResourcesResponse>({
|
||||
queryKey: ["gitlab-resources"],
|
||||
queryFn: () => integrationService.getGitLabResources(),
|
||||
enabled,
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes
|
||||
});
|
||||
}
|
||||
@@ -126,6 +126,11 @@ export enum I18nKey {
|
||||
SETTINGS$GITHUB = "SETTINGS$GITHUB",
|
||||
SETTINGS$AZURE_DEVOPS = "SETTINGS$AZURE_DEVOPS",
|
||||
SETTINGS$SLACK = "SETTINGS$SLACK",
|
||||
COMMON$STATUS = "COMMON$STATUS",
|
||||
SETTINGS$GITLAB_NOT_CONNECTED = "SETTINGS$GITLAB_NOT_CONNECTED",
|
||||
SETTINGS$GITLAB_REINSTALL_WEBHOOK = "SETTINGS$GITLAB_REINSTALL_WEBHOOK",
|
||||
SETTINGS$GITLAB_INSTALLING_WEBHOOK = "SETTINGS$GITLAB_INSTALLING_WEBHOOK",
|
||||
SETTINGS$GITLAB = "SETTINGS$GITLAB",
|
||||
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
|
||||
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
|
||||
GIT$GITLAB_API = "GIT$GITLAB_API",
|
||||
@@ -618,6 +623,22 @@ export enum I18nKey {
|
||||
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
|
||||
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
|
||||
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
|
||||
GITLAB$WEBHOOK_MANAGER_TITLE = "GITLAB$WEBHOOK_MANAGER_TITLE",
|
||||
GITLAB$WEBHOOK_MANAGER_DESCRIPTION = "GITLAB$WEBHOOK_MANAGER_DESCRIPTION",
|
||||
GITLAB$WEBHOOK_MANAGER_LOADING = "GITLAB$WEBHOOK_MANAGER_LOADING",
|
||||
GITLAB$WEBHOOK_MANAGER_ERROR = "GITLAB$WEBHOOK_MANAGER_ERROR",
|
||||
GITLAB$WEBHOOK_MANAGER_NO_RESOURCES = "GITLAB$WEBHOOK_MANAGER_NO_RESOURCES",
|
||||
GITLAB$WEBHOOK_REINSTALL = "GITLAB$WEBHOOK_REINSTALL",
|
||||
GITLAB$WEBHOOK_REINSTALLING = "GITLAB$WEBHOOK_REINSTALLING",
|
||||
GITLAB$WEBHOOK_REINSTALL_SUCCESS = "GITLAB$WEBHOOK_REINSTALL_SUCCESS",
|
||||
GITLAB$WEBHOOK_COLUMN_RESOURCE = "GITLAB$WEBHOOK_COLUMN_RESOURCE",
|
||||
GITLAB$WEBHOOK_COLUMN_TYPE = "GITLAB$WEBHOOK_COLUMN_TYPE",
|
||||
GITLAB$WEBHOOK_COLUMN_STATUS = "GITLAB$WEBHOOK_COLUMN_STATUS",
|
||||
GITLAB$WEBHOOK_COLUMN_ACTION = "GITLAB$WEBHOOK_COLUMN_ACTION",
|
||||
GITLAB$WEBHOOK_STATUS_INSTALLED = "GITLAB$WEBHOOK_STATUS_INSTALLED",
|
||||
GITLAB$WEBHOOK_STATUS_NOT_INSTALLED = "GITLAB$WEBHOOK_STATUS_NOT_INSTALLED",
|
||||
GITLAB$WEBHOOK_STATUS_FAILED = "GITLAB$WEBHOOK_STATUS_FAILED",
|
||||
GITLAB$WEBHOOK_REINSTALL_FAILED = "GITLAB$WEBHOOK_REINSTALL_FAILED",
|
||||
BITBUCKET$TOKEN_LABEL = "BITBUCKET$TOKEN_LABEL",
|
||||
BITBUCKET$HOST_LABEL = "BITBUCKET$HOST_LABEL",
|
||||
BITBUCKET$GET_TOKEN = "BITBUCKET$GET_TOKEN",
|
||||
|
||||
@@ -2015,6 +2015,86 @@
|
||||
"de": "Slack",
|
||||
"uk": "Slack"
|
||||
},
|
||||
"COMMON$STATUS": {
|
||||
"en": "Status",
|
||||
"ja": "ステータス",
|
||||
"zh-CN": "状态",
|
||||
"zh-TW": "狀態",
|
||||
"ko-KR": "상태",
|
||||
"no": "Status",
|
||||
"it": "Stato",
|
||||
"pt": "Status",
|
||||
"es": "Estado",
|
||||
"ar": "الحالة",
|
||||
"fr": "Statut",
|
||||
"tr": "Durum",
|
||||
"de": "Status",
|
||||
"uk": "Статус"
|
||||
},
|
||||
"SETTINGS$GITLAB_NOT_CONNECTED": {
|
||||
"en": "Not Connected",
|
||||
"ja": "未接続",
|
||||
"zh-CN": "未连接",
|
||||
"zh-TW": "未連接",
|
||||
"ko-KR": "연결되지 않음",
|
||||
"no": "Ikke tilkoblet",
|
||||
"it": "Non connesso",
|
||||
"pt": "Não conectado",
|
||||
"es": "No conectado",
|
||||
"ar": "غير متصل",
|
||||
"fr": "Non connecté",
|
||||
"tr": "Bağlı değil",
|
||||
"de": "Nicht verbunden",
|
||||
"uk": "Не підключено"
|
||||
},
|
||||
"SETTINGS$GITLAB_REINSTALL_WEBHOOK": {
|
||||
"en": "Reinstall Webhook",
|
||||
"ja": "Webhookを再インストール",
|
||||
"zh-CN": "重新安装 Webhook",
|
||||
"zh-TW": "重新安裝 Webhook",
|
||||
"ko-KR": "Webhook 재설치",
|
||||
"no": "Installer Webhook på nytt",
|
||||
"it": "Reinstalla Webhook",
|
||||
"pt": "Reinstalar Webhook",
|
||||
"es": "Reinstalar Webhook",
|
||||
"ar": "إعادة تثبيت Webhook",
|
||||
"fr": "Réinstaller le Webhook",
|
||||
"tr": "Webhook'u Yeniden Kur",
|
||||
"de": "Webhook neu installieren",
|
||||
"uk": "Перевстановити Webhook"
|
||||
},
|
||||
"SETTINGS$GITLAB_INSTALLING_WEBHOOK": {
|
||||
"en": "Installing GitLab webhook, please wait a few minutes.",
|
||||
"ja": "GitLabのWebhookをインストールしています。数分お待ちください。",
|
||||
"zh-CN": "正在安装 GitLab webhook,请稍等几分钟。",
|
||||
"zh-TW": "正在安裝 GitLab webhook,請稍候幾分鐘。",
|
||||
"ko-KR": "GitLab webhook을 설치 중입니다. 잠시만 기다려주세요.",
|
||||
"no": "Installerer GitLab-webhook, vennligst vent noen minutter.",
|
||||
"it": "Installazione del webhook GitLab in corso, attendi alcuni minuti.",
|
||||
"pt": "Instalando o webhook do GitLab, por favor aguarde alguns minutos.",
|
||||
"es": "Instalando el webhook de GitLab, por favor espera unos minutos.",
|
||||
"ar": "يتم تثبيت Webhook الخاص بـ GitLab، يرجى الانتظار لبضع دقائق.",
|
||||
"fr": "Installation du webhook GitLab, veuillez patienter quelques minutes.",
|
||||
"tr": "GitLab webhook'u yükleniyor, lütfen birkaç dakika bekleyin.",
|
||||
"de": "GitLab-Webhook wird installiert. Bitte warten Sie einige Minuten.",
|
||||
"uk": "Встановлення GitLab webhook, зачекайте кілька хвилин."
|
||||
},
|
||||
"SETTINGS$GITLAB": {
|
||||
"en": "GitLab",
|
||||
"ja": "GitLab",
|
||||
"zh-CN": "GitLab",
|
||||
"zh-TW": "GitLab",
|
||||
"ko-KR": "GitLab",
|
||||
"no": "GitLab",
|
||||
"it": "GitLab",
|
||||
"pt": "GitLab",
|
||||
"es": "GitLab",
|
||||
"ar": "GitLab",
|
||||
"fr": "GitLab",
|
||||
"tr": "GitLab",
|
||||
"de": "GitLab",
|
||||
"uk": "GitLab"
|
||||
},
|
||||
"SETTINGS$NAV_LLM": {
|
||||
"en": "LLM",
|
||||
"ja": "LLM",
|
||||
@@ -9887,6 +9967,262 @@
|
||||
"de": "klicken Sie hier für Anweisungen",
|
||||
"uk": "натисніть тут, щоб отримати інструкції"
|
||||
},
|
||||
"GITLAB$WEBHOOK_MANAGER_TITLE": {
|
||||
"en": "Webhook Management",
|
||||
"ja": "Webhook管理",
|
||||
"zh-CN": "Webhook管理",
|
||||
"zh-TW": "Webhook管理",
|
||||
"ko-KR": "웹훅 관리",
|
||||
"no": "Webhook-administrasjon",
|
||||
"it": "Gestione Webhook",
|
||||
"pt": "Gerenciamento de Webhook",
|
||||
"es": "Gestión de Webhook",
|
||||
"ar": "إدارة Webhook",
|
||||
"fr": "Gestion des Webhooks",
|
||||
"tr": "Webhook Yönetimi",
|
||||
"de": "Webhook-Verwaltung",
|
||||
"uk": "Керування Webhook"
|
||||
},
|
||||
"GITLAB$WEBHOOK_MANAGER_DESCRIPTION": {
|
||||
"en": "Manage webhooks for your GitLab projects and groups. Webhooks enable OpenHands to receive notifications from GitLab. Note: If a webhook is already installed, you must first delete it through the GitLab UI before reinstalling.",
|
||||
"ja": "GitLabプロジェクトとグループのWebhookを管理します。WebhookによりOpenHandsはGitLabから通知を受け取ることができます。注:Webhookが既にインストールされている場合は、再インストールする前にGitLab UIから削除する必要があります。",
|
||||
"zh-CN": "管理您的GitLab项目和组的Webhook。Webhook使OpenHands能够接收来自GitLab的通知。注意:如果Webhook已安装,您必须先通过GitLab UI删除它,然后才能重新安装。",
|
||||
"zh-TW": "管理您的GitLab專案和群組的Webhook。Webhook使OpenHands能夠接收來自GitLab的通知。注意:如果Webhook已安裝,您必須先透過GitLab UI刪除它,然後才能重新安裝。",
|
||||
"ko-KR": "GitLab 프로젝트 및 그룹의 웹훅을 관리합니다. 웹훅을 통해 OpenHands가 GitLab에서 알림을 받을 수 있습니다. 참고: 웹훅이 이미 설치되어 있는 경우 재설치하기 전에 GitLab UI를 통해 먼저 삭제해야 합니다.",
|
||||
"no": "Administrer webhooks for dine GitLab-prosjekter og grupper. Webhooks gjør det mulig for OpenHands å motta varsler fra GitLab. Merk: Hvis en webhook allerede er installert, må du først slette den via GitLab-grensesnittet før du installerer den på nytt.",
|
||||
"it": "Gestisci i webhook per i tuoi progetti e gruppi GitLab. I webhook consentono a OpenHands di ricevere notifiche da GitLab. Nota: se un webhook è già installato, devi prima eliminarlo tramite l'interfaccia utente di GitLab prima di reinstallarlo.",
|
||||
"pt": "Gerencie webhooks para seus projetos e grupos do GitLab. Os webhooks permitem que o OpenHands receba notificações do GitLab. Nota: Se um webhook já estiver instalado, você deve primeiro excluí-lo através da interface do GitLab antes de reinstalá-lo.",
|
||||
"es": "Administre webhooks para sus proyectos y grupos de GitLab. Los webhooks permiten que OpenHands reciba notificaciones de GitLab. Nota: Si un webhook ya está instalado, primero debe eliminarlo a través de la interfaz de GitLab antes de reinstalarlo.",
|
||||
"ar": "إدارة webhooks لمشاريعك ومجموعاتك في GitLab. تمكن Webhooks OpenHands من تلقي الإشعارات من GitLab. ملاحظة: إذا كان webhook مثبتًا بالفعل، يجب عليك أولاً حذفه من خلال واجهة GitLab قبل إعادة التثبيت.",
|
||||
"fr": "Gérez les webhooks pour vos projets et groupes GitLab. Les webhooks permettent à OpenHands de recevoir des notifications de GitLab. Remarque : Si un webhook est déjà installé, vous devez d'abord le supprimer via l'interface GitLab avant de le réinstaller.",
|
||||
"tr": "GitLab projeleriniz ve gruplarınız için webhook'ları yönetin. Webhook'lar OpenHands'in GitLab'dan bildirim almasını sağlar. Not: Bir webhook zaten yüklüyse, yeniden yüklemeden önce GitLab arayüzü üzerinden silmeniz gerekir.",
|
||||
"de": "Verwalten Sie Webhooks für Ihre GitLab-Projekte und -Gruppen. Webhooks ermöglichen es OpenHands, Benachrichtigungen von GitLab zu empfangen. Hinweis: Wenn ein Webhook bereits installiert ist, müssen Sie ihn zuerst über die GitLab-Benutzeroberfläche löschen, bevor Sie ihn neu installieren.",
|
||||
"uk": "Керуйте вебхуками для ваших проектів та груп GitLab. Вебхуки дозволяють OpenHands отримувати сповіщення від GitLab. Примітка: Якщо вебхук вже встановлено, ви повинні спочатку видалити його через інтерфейс GitLab перед повторним встановленням."
|
||||
},
|
||||
"GITLAB$WEBHOOK_MANAGER_LOADING": {
|
||||
"en": "Loading resources...",
|
||||
"ja": "リソースを読み込み中...",
|
||||
"zh-CN": "正在加载资源...",
|
||||
"zh-TW": "正在載入資源...",
|
||||
"ko-KR": "리소스 로드 중...",
|
||||
"no": "Laster ressurser...",
|
||||
"it": "Caricamento risorse...",
|
||||
"pt": "Carregando recursos...",
|
||||
"es": "Cargando recursos...",
|
||||
"ar": "جارٍ تحميل الموارد...",
|
||||
"fr": "Chargement des ressources...",
|
||||
"tr": "Kaynaklar yükleniyor...",
|
||||
"de": "Ressourcen werden geladen...",
|
||||
"uk": "Завантаження ресурсів..."
|
||||
},
|
||||
"GITLAB$WEBHOOK_MANAGER_ERROR": {
|
||||
"en": "Failed to load resources. Please try again.",
|
||||
"ja": "リソースの読み込みに失敗しました。もう一度お試しください。",
|
||||
"zh-CN": "加载资源失败。请重试。",
|
||||
"zh-TW": "載入資源失敗。請重試。",
|
||||
"ko-KR": "리소스 로드에 실패했습니다. 다시 시도해주세요.",
|
||||
"no": "Kunne ikke laste ressurser. Vennligst prøv igjen.",
|
||||
"it": "Impossibile caricare le risorse. Riprova.",
|
||||
"pt": "Falha ao carregar recursos. Por favor, tente novamente.",
|
||||
"es": "Error al cargar recursos. Por favor, inténtelo de nuevo.",
|
||||
"ar": "فشل تحميل الموارد. يرجى المحاولة مرة أخرى.",
|
||||
"fr": "Échec du chargement des ressources. Veuillez réessayer.",
|
||||
"tr": "Kaynaklar yüklenemedi. Lütfen tekrar deneyin.",
|
||||
"de": "Ressourcen konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
|
||||
"uk": "Не вдалося завантажити ресурси. Будь ласка, спробуйте ще раз."
|
||||
},
|
||||
"GITLAB$WEBHOOK_MANAGER_NO_RESOURCES": {
|
||||
"en": "No projects or groups found where you have admin access.",
|
||||
"ja": "管理者アクセス権を持つプロジェクトまたはグループが見つかりませんでした。",
|
||||
"zh-CN": "未找到您具有管理员访问权限的项目或组。",
|
||||
"zh-TW": "未找到您具有管理員存取權限的專案或群組。",
|
||||
"ko-KR": "관리자 액세스 권한이 있는 프로젝트 또는 그룹을 찾을 수 없습니다.",
|
||||
"no": "Ingen prosjekter eller grupper funnet der du har administratortilgang.",
|
||||
"it": "Nessun progetto o gruppo trovato in cui hai accesso amministratore.",
|
||||
"pt": "Nenhum projeto ou grupo encontrado onde você tem acesso de administrador.",
|
||||
"es": "No se encontraron proyectos o grupos donde tenga acceso de administrador.",
|
||||
"ar": "لم يتم العثور على مشاريع أو مجموعات لديك فيها وصول المسؤول.",
|
||||
"fr": "Aucun projet ou groupe trouvé où vous avez un accès administrateur.",
|
||||
"tr": "Yönetici erişiminizin olduğu proje veya grup bulunamadı.",
|
||||
"de": "Keine Projekte oder Gruppen gefunden, auf die Sie Administratorzugriff haben.",
|
||||
"uk": "Не знайдено проектів або груп, де ви маєте адміністраторський доступ."
|
||||
},
|
||||
"GITLAB$WEBHOOK_REINSTALL": {
|
||||
"en": "Reinstall",
|
||||
"ja": "再インストール",
|
||||
"zh-CN": "重新安装",
|
||||
"zh-TW": "重新安裝",
|
||||
"ko-KR": "재설치",
|
||||
"no": "Installer på nytt",
|
||||
"it": "Reinstalla",
|
||||
"pt": "Reinstalar",
|
||||
"es": "Reinstalar",
|
||||
"ar": "إعادة التثبيت",
|
||||
"fr": "Réinstaller",
|
||||
"tr": "Yeniden Yükle",
|
||||
"de": "Neu installieren",
|
||||
"uk": "Перевстановити"
|
||||
},
|
||||
"GITLAB$WEBHOOK_REINSTALLING": {
|
||||
"en": "Reinstalling...",
|
||||
"ja": "再インストール中...",
|
||||
"zh-CN": "正在重新安装...",
|
||||
"zh-TW": "正在重新安裝...",
|
||||
"ko-KR": "재설치 중...",
|
||||
"no": "Installerer på nytt...",
|
||||
"it": "Reinstallazione...",
|
||||
"pt": "Reinstalando...",
|
||||
"es": "Reinstalando...",
|
||||
"ar": "جارٍ إعادة التثبيت...",
|
||||
"fr": "Réinstallation...",
|
||||
"tr": "Yeniden yükleniyor...",
|
||||
"de": "Wird neu installiert...",
|
||||
"uk": "Перевстановлення..."
|
||||
},
|
||||
"GITLAB$WEBHOOK_REINSTALL_SUCCESS": {
|
||||
"en": "Webhook reinstalled successfully",
|
||||
"ja": "ウェブフックの再インストールが完了しました",
|
||||
"zh-CN": "Webhook 重新安装成功",
|
||||
"zh-TW": "Webhook 重新安裝成功",
|
||||
"ko-KR": "웹훅 재설치 성공",
|
||||
"no": "Webhook ble installert på nytt",
|
||||
"it": "Webhook reinstallato con successo",
|
||||
"pt": "Webhook reinstalado com sucesso",
|
||||
"es": "Webhook reinstalado correctamente",
|
||||
"ar": "تم إعادة تثبيت الخطاف بنجاح",
|
||||
"fr": "Webhook réinstallé avec succès",
|
||||
"tr": "Webhook başarıyla yeniden yüklendi",
|
||||
"de": "Webhook erfolgreich neu installiert",
|
||||
"uk": "Вебхук успішно перевстановлено"
|
||||
},
|
||||
"GITLAB$WEBHOOK_COLUMN_RESOURCE": {
|
||||
"en": "Resource",
|
||||
"ja": "リソース",
|
||||
"zh-CN": "资源",
|
||||
"zh-TW": "資源",
|
||||
"ko-KR": "리소스",
|
||||
"no": "Ressurs",
|
||||
"it": "Risorsa",
|
||||
"pt": "Recurso",
|
||||
"es": "Recurso",
|
||||
"ar": "المورد",
|
||||
"fr": "Ressource",
|
||||
"tr": "Kaynak",
|
||||
"de": "Ressource",
|
||||
"uk": "Ресурс"
|
||||
},
|
||||
"GITLAB$WEBHOOK_COLUMN_TYPE": {
|
||||
"en": "Type",
|
||||
"ja": "タイプ",
|
||||
"zh-CN": "类型",
|
||||
"zh-TW": "類型",
|
||||
"ko-KR": "유형",
|
||||
"no": "Type",
|
||||
"it": "Tipo",
|
||||
"pt": "Tipo",
|
||||
"es": "Tipo",
|
||||
"ar": "النوع",
|
||||
"fr": "Type",
|
||||
"tr": "Tür",
|
||||
"de": "Typ",
|
||||
"uk": "Тип"
|
||||
},
|
||||
"GITLAB$WEBHOOK_COLUMN_STATUS": {
|
||||
"en": "Status",
|
||||
"ja": "ステータス",
|
||||
"zh-CN": "状态",
|
||||
"zh-TW": "狀態",
|
||||
"ko-KR": "상태",
|
||||
"no": "Status",
|
||||
"it": "Stato",
|
||||
"pt": "Status",
|
||||
"es": "Estado",
|
||||
"ar": "الحالة",
|
||||
"fr": "Statut",
|
||||
"tr": "Durum",
|
||||
"de": "Status",
|
||||
"uk": "Статус"
|
||||
},
|
||||
"GITLAB$WEBHOOK_COLUMN_ACTION": {
|
||||
"en": "Action",
|
||||
"ja": "アクション",
|
||||
"zh-CN": "操作",
|
||||
"zh-TW": "操作",
|
||||
"ko-KR": "작업",
|
||||
"no": "Handling",
|
||||
"it": "Azione",
|
||||
"pt": "Ação",
|
||||
"es": "Acción",
|
||||
"ar": "الإجراء",
|
||||
"fr": "Action",
|
||||
"tr": "Eylem",
|
||||
"de": "Aktion",
|
||||
"uk": "Дія"
|
||||
},
|
||||
"GITLAB$WEBHOOK_STATUS_INSTALLED": {
|
||||
"en": "Installed",
|
||||
"ja": "インストール済み",
|
||||
"zh-CN": "已安装",
|
||||
"zh-TW": "已安裝",
|
||||
"ko-KR": "설치됨",
|
||||
"no": "Installert",
|
||||
"it": "Installato",
|
||||
"pt": "Instalado",
|
||||
"es": "Instalado",
|
||||
"ar": "مثبت",
|
||||
"fr": "Installé",
|
||||
"tr": "Yüklü",
|
||||
"de": "Installiert",
|
||||
"uk": "Встановлено"
|
||||
},
|
||||
"GITLAB$WEBHOOK_STATUS_NOT_INSTALLED": {
|
||||
"en": "Not Installed",
|
||||
"ja": "未インストール",
|
||||
"zh-CN": "未安装",
|
||||
"zh-TW": "未安裝",
|
||||
"ko-KR": "설치되지 않음",
|
||||
"no": "Ikke installert",
|
||||
"it": "Non installato",
|
||||
"pt": "Não instalado",
|
||||
"es": "No instalado",
|
||||
"ar": "غير مثبت",
|
||||
"fr": "Non installé",
|
||||
"tr": "Yüklü değil",
|
||||
"de": "Nicht installiert",
|
||||
"uk": "Не встановлено"
|
||||
},
|
||||
"GITLAB$WEBHOOK_STATUS_FAILED": {
|
||||
"en": "Failed",
|
||||
"ja": "失敗",
|
||||
"zh-CN": "失败",
|
||||
"zh-TW": "失敗",
|
||||
"ko-KR": "실패",
|
||||
"no": "Mislyktes",
|
||||
"it": "Fallito",
|
||||
"pt": "Falhou",
|
||||
"es": "Fallido",
|
||||
"ar": "فشل",
|
||||
"fr": "Échoué",
|
||||
"tr": "Başarısız",
|
||||
"de": "Fehlgeschlagen",
|
||||
"uk": "Помилка"
|
||||
},
|
||||
"GITLAB$WEBHOOK_REINSTALL_FAILED": {
|
||||
"en": "Failed to reinstall webhook",
|
||||
"ja": "ウェブフックの再インストールに失敗しました",
|
||||
"zh-CN": "重新安装 Webhook 失败",
|
||||
"zh-TW": "重新安裝 Webhook 失敗",
|
||||
"ko-KR": "웹훅 재설치 실패",
|
||||
"no": "Kunne ikke installere webhook på nytt",
|
||||
"it": "Reinstallazione webhook non riuscita",
|
||||
"pt": "Falha ao reinstalar webhook",
|
||||
"es": "Error al reinstalar webhook",
|
||||
"ar": "فشل في إعادة تثبيت الخطاف",
|
||||
"fr": "Échec de la réinstallation du webhook",
|
||||
"tr": "Webhook yeniden yüklenemedi",
|
||||
"de": "Webhook konnte nicht neu installiert werden",
|
||||
"uk": "Не вдалося перевстановити вебхук"
|
||||
},
|
||||
"BITBUCKET$TOKEN_LABEL": {
|
||||
"en": "Bitbucket Token",
|
||||
"ja": "Bitbucketトークン",
|
||||
|
||||
@@ -6,11 +6,13 @@ import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
|
||||
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
|
||||
import { GitLabWebhookManager } from "#/components/features/settings/git-settings/gitlab-webhook-manager";
|
||||
import { BitbucketTokenInput } from "#/components/features/settings/git-settings/bitbucket-token-input";
|
||||
import { AzureDevOpsTokenInput } from "#/components/features/settings/git-settings/azure-devops-token-input";
|
||||
import { ForgejoTokenInput } from "#/components/features/settings/git-settings/forgejo-token-input";
|
||||
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
|
||||
import { InstallSlackAppAnchor } from "#/components/features/settings/git-settings/install-slack-app-anchor";
|
||||
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
@@ -21,6 +23,7 @@ import { GitSettingInputsSkeleton } from "#/components/features/settings/git-set
|
||||
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
function GitSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -182,6 +185,33 @@ function GitSettingsScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="mt-6 flex flex-col gap-4 pb-8">
|
||||
<Typography.H3 className="text-xl">
|
||||
{t(I18nKey.SETTINGS$GITLAB)}
|
||||
</Typography.H3>
|
||||
<div className="flex items-center">
|
||||
<DebugStackframeDot
|
||||
className="w-6 h-6 shrink-0"
|
||||
color={isGitLabTokenSet ? "#BCFF8C" : "#FF684E"}
|
||||
/>
|
||||
<Typography.Text
|
||||
className="text-sm text-gray-400"
|
||||
testId="gitlab-status-text"
|
||||
>
|
||||
{t(I18nKey.COMMON$STATUS)}:{" "}
|
||||
{isGitLabTokenSet
|
||||
? t(I18nKey.STATUS$CONNECTED)
|
||||
: t(I18nKey.SETTINGS$GITLAB_NOT_CONNECTED)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isGitLabTokenSet && <GitLabWebhookManager />}
|
||||
</div>
|
||||
<div className="w-1/2 border-b border-gray-200" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="pb-1 mt-6 flex flex-col">
|
||||
|
||||
Reference in New Issue
Block a user