mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-07 22:14:03 -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!')
|
logger.warning('external_auth_token and user_id not set!')
|
||||||
return gitlab_token
|
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:
|
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'
|
groups_with_admin_access = []
|
||||||
params = {'owned': 'true', 'per_page': 100, 'top_level_only': 'true'}
|
page = 1
|
||||||
|
per_page = 100
|
||||||
|
|
||||||
try:
|
while True:
|
||||||
response, headers = await self._make_request(url, params)
|
try:
|
||||||
return response
|
url = f'{self.BASE_URL}/groups'
|
||||||
except Exception:
|
params = {
|
||||||
logger.warning('Error fetching owned groups', exc_info=True)
|
'page': str(page),
|
||||||
return []
|
'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):
|
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)
|
await self._make_request(url=url, params=params, method=RequestMethod.POST)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f'[GitLab]: Reply to MR failed {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 hashlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, Request
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
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.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 server.auth.token_manager import TokenManager
|
||||||
|
from storage.gitlab_webhook import GitlabWebhook
|
||||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||||
|
|
||||||
from openhands.core.logger import openhands_logger as logger
|
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.shared import sio
|
||||||
|
from openhands.server.user_auth import get_user_id
|
||||||
|
|
||||||
gitlab_integration_router = APIRouter(prefix='/integration')
|
gitlab_integration_router = APIRouter(prefix='/integration')
|
||||||
webhook_store = GitlabWebhookStore()
|
webhook_store = GitlabWebhookStore()
|
||||||
@@ -18,6 +31,37 @@ token_manager = TokenManager()
|
|||||||
gitlab_manager = GitlabManager(token_manager)
|
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(
|
async def verify_gitlab_signature(
|
||||||
header_webhook_secret: str, webhook_uuid: str, user_id: str
|
header_webhook_secret: str, webhook_uuid: str, user_id: str
|
||||||
):
|
):
|
||||||
@@ -83,3 +127,260 @@ async def gitlab_events(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f'Error processing GitLab event: {e}')
|
logger.exception(f'Error processing GitLab event: {e}')
|
||||||
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
|
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 webhooks[0].webhook_secret
|
||||||
return None
|
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
|
@classmethod
|
||||||
async def get_instance(cls) -> GitlabWebhookStore:
|
async def get_instance(cls) -> GitlabWebhookStore:
|
||||||
"""Get an instance of the GitlabWebhookStore.
|
"""Get an instance of the GitlabWebhookStore.
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import cast
|
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.types import GitLabResourceType
|
||||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -14,20 +18,6 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
|||||||
from openhands.integrations.service_types import GitService
|
from openhands.integrations.service_types import GitService
|
||||||
|
|
||||||
CHUNK_SIZE = 100
|
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:
|
class VerifyWebhookStatus:
|
||||||
@@ -43,77 +33,6 @@ class VerifyWebhookStatus:
|
|||||||
if status == WebhookStatus.RATE_LIMITED:
|
if status == WebhookStatus.RATE_LIMITED:
|
||||||
raise BreakLoopException()
|
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(
|
async def check_if_webhook_already_exists_on_resource(
|
||||||
self,
|
self,
|
||||||
gitlab_service: type[GitService],
|
gitlab_service: type[GitService],
|
||||||
@@ -162,23 +81,8 @@ class VerifyWebhookStatus:
|
|||||||
webhook_store: GitlabWebhookStore,
|
webhook_store: GitlabWebhookStore,
|
||||||
webhook: GitlabWebhook,
|
webhook: GitlabWebhook,
|
||||||
):
|
):
|
||||||
await self.check_if_resource_exists(
|
# Use the standalone function
|
||||||
gitlab_service=gitlab_service,
|
await verify_webhook_conditions(
|
||||||
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(
|
|
||||||
gitlab_service=gitlab_service,
|
gitlab_service=gitlab_service,
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
@@ -197,51 +101,15 @@ class VerifyWebhookStatus:
|
|||||||
"""
|
"""
|
||||||
Install webhook on resource
|
Install webhook on resource
|
||||||
"""
|
"""
|
||||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
# Use the standalone function
|
||||||
|
await install_webhook_on_resource(
|
||||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
gitlab_service=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_type=resource_type,
|
||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
webhook_name=WEBHOOK_NAME,
|
webhook_store=webhook_store,
|
||||||
webhook_url=GITLAB_WEBHOOK_URL,
|
webhook=webhook,
|
||||||
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
async def install_webhooks(self):
|
||||||
"""
|
"""
|
||||||
Periodically check the conditions for installing a webhook on resource as valid
|
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 { GetConfigResponse } from "#/api/option-service/option.types";
|
||||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||||
import { SecretsService } from "#/api/secrets-service";
|
import { SecretsService } from "#/api/secrets-service";
|
||||||
|
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||||
|
|
||||||
const VALID_OSS_CONFIG: GetConfigResponse = {
|
const VALID_OSS_CONFIG: GetConfigResponse = {
|
||||||
APP_MODE: "oss",
|
APP_MODE: "oss",
|
||||||
@@ -63,6 +64,15 @@ const renderGitSettingsScreen = () => {
|
|||||||
GITLAB$HOST_LABEL: "GitLab Host",
|
GITLAB$HOST_LABEL: "GitLab Host",
|
||||||
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
|
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
|
||||||
BITBUCKET$HOST_LABEL: "Bitbucket Host",
|
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();
|
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");
|
const submit = await screen.findByTestId("submit-button");
|
||||||
|
|
||||||
await userEvent.type(azureDevOpsInput, "test-token");
|
await userEvent.type(azureDevOpsInput, "test-token");
|
||||||
@@ -560,3 +572,101 @@ describe("Status toasts", () => {
|
|||||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
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$GITHUB = "SETTINGS$GITHUB",
|
||||||
SETTINGS$AZURE_DEVOPS = "SETTINGS$AZURE_DEVOPS",
|
SETTINGS$AZURE_DEVOPS = "SETTINGS$AZURE_DEVOPS",
|
||||||
SETTINGS$SLACK = "SETTINGS$SLACK",
|
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",
|
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
|
||||||
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
|
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
|
||||||
GIT$GITLAB_API = "GIT$GITLAB_API",
|
GIT$GITLAB_API = "GIT$GITLAB_API",
|
||||||
@@ -618,6 +623,22 @@ export enum I18nKey {
|
|||||||
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
|
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
|
||||||
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
|
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
|
||||||
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_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$TOKEN_LABEL = "BITBUCKET$TOKEN_LABEL",
|
||||||
BITBUCKET$HOST_LABEL = "BITBUCKET$HOST_LABEL",
|
BITBUCKET$HOST_LABEL = "BITBUCKET$HOST_LABEL",
|
||||||
BITBUCKET$GET_TOKEN = "BITBUCKET$GET_TOKEN",
|
BITBUCKET$GET_TOKEN = "BITBUCKET$GET_TOKEN",
|
||||||
|
|||||||
@@ -2015,6 +2015,86 @@
|
|||||||
"de": "Slack",
|
"de": "Slack",
|
||||||
"uk": "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": {
|
"SETTINGS$NAV_LLM": {
|
||||||
"en": "LLM",
|
"en": "LLM",
|
||||||
"ja": "LLM",
|
"ja": "LLM",
|
||||||
@@ -9887,6 +9967,262 @@
|
|||||||
"de": "klicken Sie hier für Anweisungen",
|
"de": "klicken Sie hier für Anweisungen",
|
||||||
"uk": "натисніть тут, щоб отримати інструкції"
|
"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": {
|
"BITBUCKET$TOKEN_LABEL": {
|
||||||
"en": "Bitbucket Token",
|
"en": "Bitbucket Token",
|
||||||
"ja": "Bitbucketトークン",
|
"ja": "Bitbucketトークン",
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { BrandButton } from "#/components/features/settings/brand-button";
|
|||||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||||
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
|
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
|
||||||
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-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 { BitbucketTokenInput } from "#/components/features/settings/git-settings/bitbucket-token-input";
|
||||||
import { AzureDevOpsTokenInput } from "#/components/features/settings/git-settings/azure-devops-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 { ForgejoTokenInput } from "#/components/features/settings/git-settings/forgejo-token-input";
|
||||||
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
|
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 { 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 { I18nKey } from "#/i18n/declaration";
|
||||||
import {
|
import {
|
||||||
displayErrorToast,
|
displayErrorToast,
|
||||||
@@ -21,6 +23,7 @@ import { GitSettingInputsSkeleton } from "#/components/features/settings/git-set
|
|||||||
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
|
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
|
||||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||||
import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration";
|
import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration";
|
||||||
|
import { Typography } from "#/ui/typography";
|
||||||
|
|
||||||
function GitSettingsScreen() {
|
function GitSettingsScreen() {
|
||||||
const { t } = useTranslation();
|
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 && (
|
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||||
<>
|
<>
|
||||||
<div className="pb-1 mt-6 flex flex-col">
|
<div className="pb-1 mt-6 flex flex-col">
|
||||||
|
|||||||
Reference in New Issue
Block a user