feat: allow manual reinstallation for gitlab resolver (#12184)

This commit is contained in:
Hiep Le
2026-01-05 12:05:20 +07:00
committed by GitHub
parent 5bd8695ab8
commit 6f86e589c8
23 changed files with 3725 additions and 157 deletions

View File

@@ -80,22 +80,52 @@ class SaaSGitLabService(GitLabService):
logger.warning('external_auth_token and user_id not set!')
return gitlab_token
async def get_owned_groups(self) -> list[dict]:
async def get_owned_groups(self, min_access_level: int = 40) -> list[dict]:
"""
Get all groups for which the current user is the owner.
Get all top-level groups where the current user has admin access.
This method supports pagination and fetches all groups where the user has
at least the specified access level.
Args:
min_access_level: Minimum access level required (default: 40 for Maintainer or Owner)
- 40: Maintainer or Owner
- 50: Owner only
Returns:
list[dict]: A list of groups owned by the current user.
list[dict]: A list of groups where user has the specified access level or higher.
"""
url = f'{self.BASE_URL}/groups'
params = {'owned': 'true', 'per_page': 100, 'top_level_only': 'true'}
groups_with_admin_access = []
page = 1
per_page = 100
try:
response, headers = await self._make_request(url, params)
return response
except Exception:
logger.warning('Error fetching owned groups', exc_info=True)
return []
while True:
try:
url = f'{self.BASE_URL}/groups'
params = {
'page': str(page),
'per_page': str(per_page),
'min_access_level': min_access_level,
'top_level_only': 'true',
}
response, headers = await self._make_request(url, params)
if not response:
break
groups_with_admin_access.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
except Exception:
logger.warning(f'Error fetching groups on page {page}', exc_info=True)
break
return groups_with_admin_access
async def add_owned_projects_and_groups_to_db(self, owned_personal_projects):
"""
@@ -527,3 +557,55 @@ class SaaSGitLabService(GitLabService):
await self._make_request(url=url, params=params, method=RequestMethod.POST)
except Exception as e:
logger.exception(f'[GitLab]: Reply to MR failed {e}')
async def get_user_resources_with_admin_access(
self,
) -> tuple[list[dict], list[dict]]:
"""
Get all projects and groups where the current user has admin access (maintainer or owner).
Returns:
tuple[list[dict], list[dict]]: A tuple containing:
- list of projects where user has admin access
- list of groups where user has admin access
"""
projects_with_admin_access = []
groups_with_admin_access = []
# Fetch all projects the user is a member of
page = 1
per_page = 100
while True:
try:
url = f'{self.BASE_URL}/projects'
params = {
'page': str(page),
'per_page': str(per_page),
'membership': 1,
'min_access_level': 40, # Maintainer or Owner
}
response, headers = await self._make_request(url, params)
if not response:
break
projects_with_admin_access.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
except Exception:
logger.warning(f'Error fetching projects on page {page}', exc_info=True)
break
# Fetch all groups where user is owner or maintainer
groups_with_admin_access = await self.get_owned_groups(min_access_level=40)
logger.info(
f'Found {len(projects_with_admin_access)} projects and {len(groups_with_admin_access)} groups with admin access'
)
return projects_with_admin_access, groups_with_admin_access

View 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

View File

@@ -1,15 +1,28 @@
import asyncio
import hashlib
import json
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from integrations.gitlab.gitlab_manager import GitlabManager
from integrations.gitlab.gitlab_service import SaaSGitLabService
from integrations.gitlab.webhook_installation import (
BreakLoopException,
install_webhook_on_resource,
verify_webhook_conditions,
)
from integrations.models import Message, SourceType
from integrations.types import GitLabResourceType
from integrations.utils import GITLAB_WEBHOOK_URL
from pydantic import BaseModel
from server.auth.token_manager import TokenManager
from storage.gitlab_webhook import GitlabWebhook
from storage.gitlab_webhook_store import GitlabWebhookStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.server.shared import sio
from openhands.server.user_auth import get_user_id
gitlab_integration_router = APIRouter(prefix='/integration')
webhook_store = GitlabWebhookStore()
@@ -18,6 +31,37 @@ token_manager = TokenManager()
gitlab_manager = GitlabManager(token_manager)
# Request/Response models
class ResourceIdentifier(BaseModel):
type: GitLabResourceType
id: str
class ReinstallWebhookRequest(BaseModel):
resource: ResourceIdentifier
class ResourceWithWebhookStatus(BaseModel):
id: str
name: str
full_path: str
type: str
webhook_installed: bool
webhook_uuid: str | None
last_synced: str | None
class GitLabResourcesResponse(BaseModel):
resources: list[ResourceWithWebhookStatus]
class ResourceInstallationResult(BaseModel):
resource_id: str
resource_type: str
success: bool
error: str | None
async def verify_gitlab_signature(
header_webhook_secret: str, webhook_uuid: str, user_id: str
):
@@ -83,3 +127,260 @@ async def gitlab_events(
except Exception as e:
logger.exception(f'Error processing GitLab event: {e}')
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
@gitlab_integration_router.get('/gitlab/resources')
async def get_gitlab_resources(
user_id: str = Depends(get_user_id),
) -> GitLabResourcesResponse:
"""Get all GitLab projects and groups where the user has admin access.
Returns a list of resources with their webhook installation status.
"""
try:
# Get GitLab service for the user
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
if not isinstance(gitlab_service, SaaSGitLabService):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Only SaaS GitLab service is supported',
)
# Fetch projects and groups with admin access
projects, groups = await gitlab_service.get_user_resources_with_admin_access()
# Filter out projects that belong to a group (nested projects)
# We only want top-level personal projects since group webhooks cover nested projects
filtered_projects = [
project
for project in projects
if project.get('namespace', {}).get('kind') != 'group'
]
# Extract IDs for bulk fetching
project_ids = [str(project['id']) for project in filtered_projects]
group_ids = [str(group['id']) for group in groups]
# Bulk fetch webhook records from database (organization-wide)
(
project_webhook_map,
group_webhook_map,
) = await webhook_store.get_webhooks_by_resources(project_ids, group_ids)
# Parallelize GitLab API calls to check webhook status for all resources
async def check_project_webhook(project):
project_id = str(project['id'])
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
GitLabResourceType.PROJECT, project_id, GITLAB_WEBHOOK_URL
)
return project_id, webhook_exists
async def check_group_webhook(group):
group_id = str(group['id'])
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
GitLabResourceType.GROUP, group_id, GITLAB_WEBHOOK_URL
)
return group_id, webhook_exists
# Gather all API calls in parallel
project_checks = [
check_project_webhook(project) for project in filtered_projects
]
group_checks = [check_group_webhook(group) for group in groups]
# Execute all checks concurrently
all_results = await asyncio.gather(*(project_checks + group_checks))
# Split results back into projects and groups
num_projects = len(filtered_projects)
project_results = all_results[:num_projects]
group_results = all_results[num_projects:]
# Build response
resources = []
# Add projects with their webhook status
for project, (project_id, webhook_exists) in zip(
filtered_projects, project_results
):
webhook = project_webhook_map.get(project_id)
resources.append(
ResourceWithWebhookStatus(
id=project_id,
name=project.get('name', ''),
full_path=project.get('path_with_namespace', ''),
type='project',
webhook_installed=webhook_exists,
webhook_uuid=webhook.webhook_uuid if webhook else None,
last_synced=(
webhook.last_synced.isoformat()
if webhook and webhook.last_synced
else None
),
)
)
# Add groups with their webhook status
for group, (group_id, webhook_exists) in zip(groups, group_results):
webhook = group_webhook_map.get(group_id)
resources.append(
ResourceWithWebhookStatus(
id=group_id,
name=group.get('name', ''),
full_path=group.get('full_path', ''),
type='group',
webhook_installed=webhook_exists,
webhook_uuid=webhook.webhook_uuid if webhook else None,
last_synced=(
webhook.last_synced.isoformat()
if webhook and webhook.last_synced
else None
),
)
)
logger.info(
'Retrieved GitLab resources',
extra={
'user_id': user_id,
'project_count': len(projects),
'group_count': len(groups),
},
)
return GitLabResourcesResponse(resources=resources)
except HTTPException:
raise
except Exception as e:
logger.exception(f'Error retrieving GitLab resources: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve GitLab resources',
)
@gitlab_integration_router.post('/gitlab/reinstall-webhook')
async def reinstall_gitlab_webhook(
body: ReinstallWebhookRequest,
user_id: str = Depends(get_user_id),
) -> ResourceInstallationResult:
"""Reinstall GitLab webhook for a specific resource immediately.
This endpoint validates permissions, resets webhook status in the database,
and immediately installs the webhook on the specified resource.
"""
try:
# Get GitLab service for the user
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
if not isinstance(gitlab_service, SaaSGitLabService):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Only SaaS GitLab service is supported',
)
resource_id = body.resource.id
resource_type = body.resource.type
# Check if user has admin access to this resource
(
has_admin_access,
check_status,
) = await gitlab_service.check_user_has_admin_access_to_resource(
resource_type, resource_id
)
if not has_admin_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User does not have admin access to this resource',
)
# Reset webhook in database (organization-wide, not user-specific)
# This allows any admin user to reinstall webhooks
await webhook_store.reset_webhook_for_reinstallation_by_resource(
resource_type, resource_id, user_id
)
# Get or create webhook record (without user_id filter)
webhook = await webhook_store.get_webhook_by_resource_only(
resource_type, resource_id
)
if not webhook:
# Create new webhook record
webhook = GitlabWebhook(
user_id=user_id, # Track who created it
project_id=resource_id
if resource_type == GitLabResourceType.PROJECT
else None,
group_id=resource_id
if resource_type == GitLabResourceType.GROUP
else None,
webhook_exists=False,
)
await webhook_store.store_webhooks([webhook])
# Fetch it again to get the ID (without user_id filter)
webhook = await webhook_store.get_webhook_by_resource_only(
resource_type, resource_id
)
# Verify conditions and install webhook
try:
await verify_webhook_conditions(
gitlab_service=gitlab_service,
resource_type=resource_type,
resource_id=resource_id,
webhook_store=webhook_store,
webhook=webhook,
)
# Install the webhook
webhook_id, install_status = await install_webhook_on_resource(
gitlab_service=gitlab_service,
resource_type=resource_type,
resource_id=resource_id,
webhook_store=webhook_store,
webhook=webhook,
)
if webhook_id:
logger.info(
'GitLab webhook reinstalled successfully',
extra={
'user_id': user_id,
'resource_type': resource_type.value,
'resource_id': resource_id,
},
)
return ResourceInstallationResult(
resource_id=resource_id,
resource_type=resource_type.value,
success=True,
error=None,
)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to install webhook',
)
except BreakLoopException:
# Conditions not met or webhook already exists
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Webhook installation conditions not met or webhook already exists',
)
except HTTPException:
raise
except Exception as e:
logger.exception(f'Error reinstalling GitLab webhook: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reinstall webhook',
)

View File

@@ -220,6 +220,127 @@ class GitlabWebhookStore:
return webhooks[0].webhook_secret
return None
async def get_webhook_by_resource_only(
self, resource_type: GitLabResourceType, resource_id: str
) -> GitlabWebhook | None:
"""Get a webhook by resource without filtering by user_id.
This allows any admin user in the organization to manage webhooks,
not just the original installer.
Args:
resource_type: The type of resource (PROJECT or GROUP)
resource_id: The ID of the resource
Returns:
GitlabWebhook object if found, None otherwise
"""
async with self.a_session_maker() as session:
if resource_type == GitLabResourceType.PROJECT:
query = select(GitlabWebhook).where(
GitlabWebhook.project_id == resource_id
)
else: # GROUP
query = select(GitlabWebhook).where(
GitlabWebhook.group_id == resource_id
)
result = await session.execute(query)
webhook = result.scalars().first()
return webhook
async def get_webhooks_by_resources(
self, project_ids: list[str], group_ids: list[str]
) -> tuple[dict[str, GitlabWebhook], dict[str, GitlabWebhook]]:
"""Bulk fetch webhooks for multiple resources.
This is more efficient than fetching one at a time in a loop.
Args:
project_ids: List of project IDs to fetch
group_ids: List of group IDs to fetch
Returns:
Tuple of (project_webhook_map, group_webhook_map)
"""
async with self.a_session_maker() as session:
project_webhook_map = {}
group_webhook_map = {}
# Fetch all project webhooks in one query
if project_ids:
project_query = select(GitlabWebhook).where(
GitlabWebhook.project_id.in_(project_ids)
)
result = await session.execute(project_query)
project_webhooks = result.scalars().all()
project_webhook_map = {wh.project_id: wh for wh in project_webhooks}
# Fetch all group webhooks in one query
if group_ids:
group_query = select(GitlabWebhook).where(
GitlabWebhook.group_id.in_(group_ids)
)
result = await session.execute(group_query)
group_webhooks = result.scalars().all()
group_webhook_map = {wh.group_id: wh for wh in group_webhooks}
return project_webhook_map, group_webhook_map
async def reset_webhook_for_reinstallation_by_resource(
self, resource_type: GitLabResourceType, resource_id: str, updating_user_id: str
) -> bool:
"""Reset webhook for reinstallation without filtering by user_id.
This allows any admin user to reset webhooks, and updates the user_id
to track who last modified it.
Args:
resource_type: The type of resource (PROJECT or GROUP)
resource_id: The ID of the resource
updating_user_id: The user ID performing the update (for audit purposes)
Returns:
True if webhook was reset, False if not found
"""
async with self.a_session_maker() as session:
async with session.begin():
if resource_type == GitLabResourceType.PROJECT:
update_statement = (
update(GitlabWebhook)
.where(GitlabWebhook.project_id == resource_id)
.values(
webhook_exists=False,
webhook_uuid=None,
user_id=updating_user_id, # Update to track who modified it
)
)
else: # GROUP
update_statement = (
update(GitlabWebhook)
.where(GitlabWebhook.group_id == resource_id)
.values(
webhook_exists=False,
webhook_uuid=None,
user_id=updating_user_id, # Update to track who modified it
)
)
result = await session.execute(update_statement)
rows_updated = result.rowcount
logger.info(
'Reset webhook for reinstallation (organization-wide)',
extra={
'updating_user_id': updating_user_id,
'resource_type': resource_type.value,
'resource_id': resource_id,
'rows_updated': rows_updated,
},
)
return rows_updated > 0
@classmethod
async def get_instance(cls) -> GitlabWebhookStore:
"""Get an instance of the GitlabWebhookStore.

View File

@@ -1,7 +1,11 @@
import asyncio
from typing import cast
from uuid import uuid4
from integrations.gitlab.webhook_installation import (
BreakLoopException,
install_webhook_on_resource,
verify_webhook_conditions,
)
from integrations.types import GitLabResourceType
from integrations.utils import GITLAB_WEBHOOK_URL
from sqlalchemy import text
@@ -14,20 +18,6 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.service_types import GitService
CHUNK_SIZE = 100
WEBHOOK_NAME = 'OpenHands Resolver'
SCOPES: list[str] = [
'note_events',
'merge_requests_events',
'confidential_issues_events',
'issues_events',
'confidential_note_events',
'job_events',
'pipeline_events',
]
class BreakLoopException(Exception):
pass
class VerifyWebhookStatus:
@@ -43,77 +33,6 @@ class VerifyWebhookStatus:
if status == WebhookStatus.RATE_LIMITED:
raise BreakLoopException()
async def check_if_resource_exists(
self,
gitlab_service: type[GitService],
resource_type: GitLabResourceType,
resource_id: str,
webhook_store: GitlabWebhookStore,
webhook: GitlabWebhook,
):
"""
Check if the GitLab resource still exists
"""
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
does_resource_exist, status = await gitlab_service.check_resource_exists(
resource_type, resource_id
)
logger.info(
'Does resource exists',
extra={
'does_resource_exist': does_resource_exist,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
self.determine_if_rate_limited(status)
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
await webhook_store.delete_webhook(webhook)
raise BreakLoopException()
async def check_if_user_has_admin_acccess_to_resource(
self,
gitlab_service: type[GitService],
resource_type: GitLabResourceType,
resource_id: str,
webhook_store: GitlabWebhookStore,
webhook: GitlabWebhook,
):
"""
Check is user still has permission to resource
"""
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
(
is_user_admin_of_resource,
status,
) = await gitlab_service.check_user_has_admin_access_to_resource(
resource_type, resource_id
)
logger.info(
'Is user admin',
extra={
'is_user_admin': is_user_admin_of_resource,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
self.determine_if_rate_limited(status)
if not is_user_admin_of_resource:
await webhook_store.delete_webhook(webhook)
raise BreakLoopException()
async def check_if_webhook_already_exists_on_resource(
self,
gitlab_service: type[GitService],
@@ -162,23 +81,8 @@ class VerifyWebhookStatus:
webhook_store: GitlabWebhookStore,
webhook: GitlabWebhook,
):
await self.check_if_resource_exists(
gitlab_service=gitlab_service,
resource_type=resource_type,
resource_id=resource_id,
webhook_store=webhook_store,
webhook=webhook,
)
await self.check_if_user_has_admin_acccess_to_resource(
gitlab_service=gitlab_service,
resource_type=resource_type,
resource_id=resource_id,
webhook_store=webhook_store,
webhook=webhook,
)
await self.check_if_webhook_already_exists_on_resource(
# Use the standalone function
await verify_webhook_conditions(
gitlab_service=gitlab_service,
resource_type=resource_type,
resource_id=resource_id,
@@ -197,51 +101,15 @@ class VerifyWebhookStatus:
"""
Install webhook on resource
"""
from integrations.gitlab.gitlab_service import SaaSGitLabService
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
webhook_uuid = f'{str(uuid4())}'
webhook_id, status = await gitlab_service.install_webhook(
# Use the standalone function
await install_webhook_on_resource(
gitlab_service=gitlab_service,
resource_type=resource_type,
resource_id=resource_id,
webhook_name=WEBHOOK_NAME,
webhook_url=GITLAB_WEBHOOK_URL,
webhook_secret=webhook_secret,
webhook_uuid=webhook_uuid,
scopes=SCOPES,
webhook_store=webhook_store,
webhook=webhook,
)
logger.info(
'Creating new webhook',
extra={
'webhook_id': webhook_id,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
self.determine_if_rate_limited(status)
if webhook_id:
await webhook_store.update_webhook(
webhook=webhook,
update_fields={
'webhook_secret': webhook_secret,
'webhook_exists': True, # webhook was created
'webhook_url': GITLAB_WEBHOOK_URL,
'scopes': SCOPES,
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
},
)
logger.info(
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
)
async def install_webhooks(self):
"""
Periodically check the conditions for installing a webhook on resource as valid

View 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

View File

@@ -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
)

View 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

View 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

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -13,6 +13,7 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { GetConfigResponse } from "#/api/option-service/option.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { SecretsService } from "#/api/secrets-service";
import { integrationService } from "#/api/integration-service/integration-service.api";
const VALID_OSS_CONFIG: GetConfigResponse = {
APP_MODE: "oss",
@@ -63,6 +64,15 @@ const renderGitSettingsScreen = () => {
GITLAB$HOST_LABEL: "GitLab Host",
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
BITBUCKET$HOST_LABEL: "Bitbucket Host",
SETTINGS$GITLAB: "GitLab",
COMMON$STATUS: "Status",
STATUS$CONNECTED: "Connected",
SETTINGS$GITLAB_NOT_CONNECTED: "Not Connected",
SETTINGS$GITLAB_REINSTALL_WEBHOOK: "Reinstall Webhook",
SETTINGS$GITLAB_INSTALLING_WEBHOOK:
"Installing GitLab webhook, please wait a few minutes.",
SETTINGS$SAVING: "Saving...",
ERROR$GENERIC: "An error occurred",
},
},
},
@@ -356,7 +366,9 @@ describe("Form submission", () => {
renderGitSettingsScreen();
const azureDevOpsInput = await screen.findByTestId("azure-devops-token-input");
const azureDevOpsInput = await screen.findByTestId(
"azure-devops-token-input",
);
const submit = await screen.findByTestId("submit-button");
await userEvent.type(azureDevOpsInput, "test-token");
@@ -560,3 +572,101 @@ describe("Status toasts", () => {
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
describe("GitLab Webhook Manager Integration", () => {
it("should not render GitLab webhook manager in OSS mode", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
// Act
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
// Assert
await waitFor(() => {
expect(
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
).not.toBeInTheDocument();
});
});
it("should not render GitLab webhook manager in SaaS mode without APP_SLUG", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
// Act
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
// Assert
await waitFor(() => {
expect(
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
).not.toBeInTheDocument();
});
});
it("should not render GitLab webhook manager when token is not set", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
// Act
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
// Assert
await waitFor(() => {
expect(
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
).not.toBeInTheDocument();
});
});
it("should render GitLab webhook manager when token is set", async () => {
// Arrange
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getResourcesSpy = vi.spyOn(
integrationService,
"getGitLabResources",
);
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: null,
},
});
getResourcesSpy.mockResolvedValue({
resources: [],
});
// Act
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
// Assert
await waitFor(() => {
expect(
screen.getByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
).toBeInTheDocument();
expect(getResourcesSpy).toHaveBeenCalled();
});
});
});

View File

@@ -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;
},
};

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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);
},
});
}

View 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
});
}

View File

@@ -126,6 +126,11 @@ export enum I18nKey {
SETTINGS$GITHUB = "SETTINGS$GITHUB",
SETTINGS$AZURE_DEVOPS = "SETTINGS$AZURE_DEVOPS",
SETTINGS$SLACK = "SETTINGS$SLACK",
COMMON$STATUS = "COMMON$STATUS",
SETTINGS$GITLAB_NOT_CONNECTED = "SETTINGS$GITLAB_NOT_CONNECTED",
SETTINGS$GITLAB_REINSTALL_WEBHOOK = "SETTINGS$GITLAB_REINSTALL_WEBHOOK",
SETTINGS$GITLAB_INSTALLING_WEBHOOK = "SETTINGS$GITLAB_INSTALLING_WEBHOOK",
SETTINGS$GITLAB = "SETTINGS$GITLAB",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
GIT$GITLAB_API = "GIT$GITLAB_API",
@@ -618,6 +623,22 @@ export enum I18nKey {
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
GITLAB$WEBHOOK_MANAGER_TITLE = "GITLAB$WEBHOOK_MANAGER_TITLE",
GITLAB$WEBHOOK_MANAGER_DESCRIPTION = "GITLAB$WEBHOOK_MANAGER_DESCRIPTION",
GITLAB$WEBHOOK_MANAGER_LOADING = "GITLAB$WEBHOOK_MANAGER_LOADING",
GITLAB$WEBHOOK_MANAGER_ERROR = "GITLAB$WEBHOOK_MANAGER_ERROR",
GITLAB$WEBHOOK_MANAGER_NO_RESOURCES = "GITLAB$WEBHOOK_MANAGER_NO_RESOURCES",
GITLAB$WEBHOOK_REINSTALL = "GITLAB$WEBHOOK_REINSTALL",
GITLAB$WEBHOOK_REINSTALLING = "GITLAB$WEBHOOK_REINSTALLING",
GITLAB$WEBHOOK_REINSTALL_SUCCESS = "GITLAB$WEBHOOK_REINSTALL_SUCCESS",
GITLAB$WEBHOOK_COLUMN_RESOURCE = "GITLAB$WEBHOOK_COLUMN_RESOURCE",
GITLAB$WEBHOOK_COLUMN_TYPE = "GITLAB$WEBHOOK_COLUMN_TYPE",
GITLAB$WEBHOOK_COLUMN_STATUS = "GITLAB$WEBHOOK_COLUMN_STATUS",
GITLAB$WEBHOOK_COLUMN_ACTION = "GITLAB$WEBHOOK_COLUMN_ACTION",
GITLAB$WEBHOOK_STATUS_INSTALLED = "GITLAB$WEBHOOK_STATUS_INSTALLED",
GITLAB$WEBHOOK_STATUS_NOT_INSTALLED = "GITLAB$WEBHOOK_STATUS_NOT_INSTALLED",
GITLAB$WEBHOOK_STATUS_FAILED = "GITLAB$WEBHOOK_STATUS_FAILED",
GITLAB$WEBHOOK_REINSTALL_FAILED = "GITLAB$WEBHOOK_REINSTALL_FAILED",
BITBUCKET$TOKEN_LABEL = "BITBUCKET$TOKEN_LABEL",
BITBUCKET$HOST_LABEL = "BITBUCKET$HOST_LABEL",
BITBUCKET$GET_TOKEN = "BITBUCKET$GET_TOKEN",

View File

@@ -2015,6 +2015,86 @@
"de": "Slack",
"uk": "Slack"
},
"COMMON$STATUS": {
"en": "Status",
"ja": "ステータス",
"zh-CN": "状态",
"zh-TW": "狀態",
"ko-KR": "상태",
"no": "Status",
"it": "Stato",
"pt": "Status",
"es": "Estado",
"ar": "الحالة",
"fr": "Statut",
"tr": "Durum",
"de": "Status",
"uk": "Статус"
},
"SETTINGS$GITLAB_NOT_CONNECTED": {
"en": "Not Connected",
"ja": "未接続",
"zh-CN": "未连接",
"zh-TW": "未連接",
"ko-KR": "연결되지 않음",
"no": "Ikke tilkoblet",
"it": "Non connesso",
"pt": "Não conectado",
"es": "No conectado",
"ar": "غير متصل",
"fr": "Non connecté",
"tr": "Bağlı değil",
"de": "Nicht verbunden",
"uk": "Не підключено"
},
"SETTINGS$GITLAB_REINSTALL_WEBHOOK": {
"en": "Reinstall Webhook",
"ja": "Webhookを再インストール",
"zh-CN": "重新安装 Webhook",
"zh-TW": "重新安裝 Webhook",
"ko-KR": "Webhook 재설치",
"no": "Installer Webhook på nytt",
"it": "Reinstalla Webhook",
"pt": "Reinstalar Webhook",
"es": "Reinstalar Webhook",
"ar": "إعادة تثبيت Webhook",
"fr": "Réinstaller le Webhook",
"tr": "Webhook'u Yeniden Kur",
"de": "Webhook neu installieren",
"uk": "Перевстановити Webhook"
},
"SETTINGS$GITLAB_INSTALLING_WEBHOOK": {
"en": "Installing GitLab webhook, please wait a few minutes.",
"ja": "GitLabのWebhookをインストールしています。数分お待ちください。",
"zh-CN": "正在安装 GitLab webhook请稍等几分钟。",
"zh-TW": "正在安裝 GitLab webhook請稍候幾分鐘。",
"ko-KR": "GitLab webhook을 설치 중입니다. 잠시만 기다려주세요.",
"no": "Installerer GitLab-webhook, vennligst vent noen minutter.",
"it": "Installazione del webhook GitLab in corso, attendi alcuni minuti.",
"pt": "Instalando o webhook do GitLab, por favor aguarde alguns minutos.",
"es": "Instalando el webhook de GitLab, por favor espera unos minutos.",
"ar": "يتم تثبيت Webhook الخاص بـ GitLab، يرجى الانتظار لبضع دقائق.",
"fr": "Installation du webhook GitLab, veuillez patienter quelques minutes.",
"tr": "GitLab webhook'u yükleniyor, lütfen birkaç dakika bekleyin.",
"de": "GitLab-Webhook wird installiert. Bitte warten Sie einige Minuten.",
"uk": "Встановлення GitLab webhook, зачекайте кілька хвилин."
},
"SETTINGS$GITLAB": {
"en": "GitLab",
"ja": "GitLab",
"zh-CN": "GitLab",
"zh-TW": "GitLab",
"ko-KR": "GitLab",
"no": "GitLab",
"it": "GitLab",
"pt": "GitLab",
"es": "GitLab",
"ar": "GitLab",
"fr": "GitLab",
"tr": "GitLab",
"de": "GitLab",
"uk": "GitLab"
},
"SETTINGS$NAV_LLM": {
"en": "LLM",
"ja": "LLM",
@@ -9887,6 +9967,262 @@
"de": "klicken Sie hier für Anweisungen",
"uk": "натисніть тут, щоб отримати інструкції"
},
"GITLAB$WEBHOOK_MANAGER_TITLE": {
"en": "Webhook Management",
"ja": "Webhook管理",
"zh-CN": "Webhook管理",
"zh-TW": "Webhook管理",
"ko-KR": "웹훅 관리",
"no": "Webhook-administrasjon",
"it": "Gestione Webhook",
"pt": "Gerenciamento de Webhook",
"es": "Gestión de Webhook",
"ar": "إدارة Webhook",
"fr": "Gestion des Webhooks",
"tr": "Webhook Yönetimi",
"de": "Webhook-Verwaltung",
"uk": "Керування Webhook"
},
"GITLAB$WEBHOOK_MANAGER_DESCRIPTION": {
"en": "Manage webhooks for your GitLab projects and groups. Webhooks enable OpenHands to receive notifications from GitLab. Note: If a webhook is already installed, you must first delete it through the GitLab UI before reinstalling.",
"ja": "GitLabプロジェクトとグループのWebhookを管理します。WebhookによりOpenHandsはGitLabから通知を受け取ることができます。注Webhookが既にインストールされている場合は、再インストールする前にGitLab UIから削除する必要があります。",
"zh-CN": "管理您的GitLab项目和组的Webhook。Webhook使OpenHands能够接收来自GitLab的通知。注意如果Webhook已安装您必须先通过GitLab UI删除它然后才能重新安装。",
"zh-TW": "管理您的GitLab專案和群組的Webhook。Webhook使OpenHands能夠接收來自GitLab的通知。注意如果Webhook已安裝您必須先透過GitLab UI刪除它然後才能重新安裝。",
"ko-KR": "GitLab 프로젝트 및 그룹의 웹훅을 관리합니다. 웹훅을 통해 OpenHands가 GitLab에서 알림을 받을 수 있습니다. 참고: 웹훅이 이미 설치되어 있는 경우 재설치하기 전에 GitLab UI를 통해 먼저 삭제해야 합니다.",
"no": "Administrer webhooks for dine GitLab-prosjekter og grupper. Webhooks gjør det mulig for OpenHands å motta varsler fra GitLab. Merk: Hvis en webhook allerede er installert, må du først slette den via GitLab-grensesnittet før du installerer den på nytt.",
"it": "Gestisci i webhook per i tuoi progetti e gruppi GitLab. I webhook consentono a OpenHands di ricevere notifiche da GitLab. Nota: se un webhook è già installato, devi prima eliminarlo tramite l'interfaccia utente di GitLab prima di reinstallarlo.",
"pt": "Gerencie webhooks para seus projetos e grupos do GitLab. Os webhooks permitem que o OpenHands receba notificações do GitLab. Nota: Se um webhook já estiver instalado, você deve primeiro excluí-lo através da interface do GitLab antes de reinstalá-lo.",
"es": "Administre webhooks para sus proyectos y grupos de GitLab. Los webhooks permiten que OpenHands reciba notificaciones de GitLab. Nota: Si un webhook ya está instalado, primero debe eliminarlo a través de la interfaz de GitLab antes de reinstalarlo.",
"ar": "إدارة webhooks لمشاريعك ومجموعاتك في GitLab. تمكن Webhooks OpenHands من تلقي الإشعارات من GitLab. ملاحظة: إذا كان webhook مثبتًا بالفعل، يجب عليك أولاً حذفه من خلال واجهة GitLab قبل إعادة التثبيت.",
"fr": "Gérez les webhooks pour vos projets et groupes GitLab. Les webhooks permettent à OpenHands de recevoir des notifications de GitLab. Remarque : Si un webhook est déjà installé, vous devez d'abord le supprimer via l'interface GitLab avant de le réinstaller.",
"tr": "GitLab projeleriniz ve gruplarınız için webhook'ları yönetin. Webhook'lar OpenHands'in GitLab'dan bildirim almasını sağlar. Not: Bir webhook zaten yüklüyse, yeniden yüklemeden önce GitLab arayüzü üzerinden silmeniz gerekir.",
"de": "Verwalten Sie Webhooks für Ihre GitLab-Projekte und -Gruppen. Webhooks ermöglichen es OpenHands, Benachrichtigungen von GitLab zu empfangen. Hinweis: Wenn ein Webhook bereits installiert ist, müssen Sie ihn zuerst über die GitLab-Benutzeroberfläche löschen, bevor Sie ihn neu installieren.",
"uk": "Керуйте вебхуками для ваших проектів та груп GitLab. Вебхуки дозволяють OpenHands отримувати сповіщення від GitLab. Примітка: Якщо вебхук вже встановлено, ви повинні спочатку видалити його через інтерфейс GitLab перед повторним встановленням."
},
"GITLAB$WEBHOOK_MANAGER_LOADING": {
"en": "Loading resources...",
"ja": "リソースを読み込み中...",
"zh-CN": "正在加载资源...",
"zh-TW": "正在載入資源...",
"ko-KR": "리소스 로드 중...",
"no": "Laster ressurser...",
"it": "Caricamento risorse...",
"pt": "Carregando recursos...",
"es": "Cargando recursos...",
"ar": "جارٍ تحميل الموارد...",
"fr": "Chargement des ressources...",
"tr": "Kaynaklar yükleniyor...",
"de": "Ressourcen werden geladen...",
"uk": "Завантаження ресурсів..."
},
"GITLAB$WEBHOOK_MANAGER_ERROR": {
"en": "Failed to load resources. Please try again.",
"ja": "リソースの読み込みに失敗しました。もう一度お試しください。",
"zh-CN": "加载资源失败。请重试。",
"zh-TW": "載入資源失敗。請重試。",
"ko-KR": "리소스 로드에 실패했습니다. 다시 시도해주세요.",
"no": "Kunne ikke laste ressurser. Vennligst prøv igjen.",
"it": "Impossibile caricare le risorse. Riprova.",
"pt": "Falha ao carregar recursos. Por favor, tente novamente.",
"es": "Error al cargar recursos. Por favor, inténtelo de nuevo.",
"ar": "فشل تحميل الموارد. يرجى المحاولة مرة أخرى.",
"fr": "Échec du chargement des ressources. Veuillez réessayer.",
"tr": "Kaynaklar yüklenemedi. Lütfen tekrar deneyin.",
"de": "Ressourcen konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
"uk": "Не вдалося завантажити ресурси. Будь ласка, спробуйте ще раз."
},
"GITLAB$WEBHOOK_MANAGER_NO_RESOURCES": {
"en": "No projects or groups found where you have admin access.",
"ja": "管理者アクセス権を持つプロジェクトまたはグループが見つかりませんでした。",
"zh-CN": "未找到您具有管理员访问权限的项目或组。",
"zh-TW": "未找到您具有管理員存取權限的專案或群組。",
"ko-KR": "관리자 액세스 권한이 있는 프로젝트 또는 그룹을 찾을 수 없습니다.",
"no": "Ingen prosjekter eller grupper funnet der du har administratortilgang.",
"it": "Nessun progetto o gruppo trovato in cui hai accesso amministratore.",
"pt": "Nenhum projeto ou grupo encontrado onde você tem acesso de administrador.",
"es": "No se encontraron proyectos o grupos donde tenga acceso de administrador.",
"ar": "لم يتم العثور على مشاريع أو مجموعات لديك فيها وصول المسؤول.",
"fr": "Aucun projet ou groupe trouvé où vous avez un accès administrateur.",
"tr": "Yönetici erişiminizin olduğu proje veya grup bulunamadı.",
"de": "Keine Projekte oder Gruppen gefunden, auf die Sie Administratorzugriff haben.",
"uk": "Не знайдено проектів або груп, де ви маєте адміністраторський доступ."
},
"GITLAB$WEBHOOK_REINSTALL": {
"en": "Reinstall",
"ja": "再インストール",
"zh-CN": "重新安装",
"zh-TW": "重新安裝",
"ko-KR": "재설치",
"no": "Installer på nytt",
"it": "Reinstalla",
"pt": "Reinstalar",
"es": "Reinstalar",
"ar": "إعادة التثبيت",
"fr": "Réinstaller",
"tr": "Yeniden Yükle",
"de": "Neu installieren",
"uk": "Перевстановити"
},
"GITLAB$WEBHOOK_REINSTALLING": {
"en": "Reinstalling...",
"ja": "再インストール中...",
"zh-CN": "正在重新安装...",
"zh-TW": "正在重新安裝...",
"ko-KR": "재설치 중...",
"no": "Installerer på nytt...",
"it": "Reinstallazione...",
"pt": "Reinstalando...",
"es": "Reinstalando...",
"ar": "جارٍ إعادة التثبيت...",
"fr": "Réinstallation...",
"tr": "Yeniden yükleniyor...",
"de": "Wird neu installiert...",
"uk": "Перевстановлення..."
},
"GITLAB$WEBHOOK_REINSTALL_SUCCESS": {
"en": "Webhook reinstalled successfully",
"ja": "ウェブフックの再インストールが完了しました",
"zh-CN": "Webhook 重新安装成功",
"zh-TW": "Webhook 重新安裝成功",
"ko-KR": "웹훅 재설치 성공",
"no": "Webhook ble installert på nytt",
"it": "Webhook reinstallato con successo",
"pt": "Webhook reinstalado com sucesso",
"es": "Webhook reinstalado correctamente",
"ar": "تم إعادة تثبيت الخطاف بنجاح",
"fr": "Webhook réinstallé avec succès",
"tr": "Webhook başarıyla yeniden yüklendi",
"de": "Webhook erfolgreich neu installiert",
"uk": "Вебхук успішно перевстановлено"
},
"GITLAB$WEBHOOK_COLUMN_RESOURCE": {
"en": "Resource",
"ja": "リソース",
"zh-CN": "资源",
"zh-TW": "資源",
"ko-KR": "리소스",
"no": "Ressurs",
"it": "Risorsa",
"pt": "Recurso",
"es": "Recurso",
"ar": "المورد",
"fr": "Ressource",
"tr": "Kaynak",
"de": "Ressource",
"uk": "Ресурс"
},
"GITLAB$WEBHOOK_COLUMN_TYPE": {
"en": "Type",
"ja": "タイプ",
"zh-CN": "类型",
"zh-TW": "類型",
"ko-KR": "유형",
"no": "Type",
"it": "Tipo",
"pt": "Tipo",
"es": "Tipo",
"ar": "النوع",
"fr": "Type",
"tr": "Tür",
"de": "Typ",
"uk": "Тип"
},
"GITLAB$WEBHOOK_COLUMN_STATUS": {
"en": "Status",
"ja": "ステータス",
"zh-CN": "状态",
"zh-TW": "狀態",
"ko-KR": "상태",
"no": "Status",
"it": "Stato",
"pt": "Status",
"es": "Estado",
"ar": "الحالة",
"fr": "Statut",
"tr": "Durum",
"de": "Status",
"uk": "Статус"
},
"GITLAB$WEBHOOK_COLUMN_ACTION": {
"en": "Action",
"ja": "アクション",
"zh-CN": "操作",
"zh-TW": "操作",
"ko-KR": "작업",
"no": "Handling",
"it": "Azione",
"pt": "Ação",
"es": "Acción",
"ar": "الإجراء",
"fr": "Action",
"tr": "Eylem",
"de": "Aktion",
"uk": "Дія"
},
"GITLAB$WEBHOOK_STATUS_INSTALLED": {
"en": "Installed",
"ja": "インストール済み",
"zh-CN": "已安装",
"zh-TW": "已安裝",
"ko-KR": "설치됨",
"no": "Installert",
"it": "Installato",
"pt": "Instalado",
"es": "Instalado",
"ar": "مثبت",
"fr": "Installé",
"tr": "Yüklü",
"de": "Installiert",
"uk": "Встановлено"
},
"GITLAB$WEBHOOK_STATUS_NOT_INSTALLED": {
"en": "Not Installed",
"ja": "未インストール",
"zh-CN": "未安装",
"zh-TW": "未安裝",
"ko-KR": "설치되지 않음",
"no": "Ikke installert",
"it": "Non installato",
"pt": "Não instalado",
"es": "No instalado",
"ar": "غير مثبت",
"fr": "Non installé",
"tr": "Yüklü değil",
"de": "Nicht installiert",
"uk": "Не встановлено"
},
"GITLAB$WEBHOOK_STATUS_FAILED": {
"en": "Failed",
"ja": "失敗",
"zh-CN": "失败",
"zh-TW": "失敗",
"ko-KR": "실패",
"no": "Mislyktes",
"it": "Fallito",
"pt": "Falhou",
"es": "Fallido",
"ar": "فشل",
"fr": "Échoué",
"tr": "Başarısız",
"de": "Fehlgeschlagen",
"uk": "Помилка"
},
"GITLAB$WEBHOOK_REINSTALL_FAILED": {
"en": "Failed to reinstall webhook",
"ja": "ウェブフックの再インストールに失敗しました",
"zh-CN": "重新安装 Webhook 失败",
"zh-TW": "重新安裝 Webhook 失敗",
"ko-KR": "웹훅 재설치 실패",
"no": "Kunne ikke installere webhook på nytt",
"it": "Reinstallazione webhook non riuscita",
"pt": "Falha ao reinstalar webhook",
"es": "Error al reinstalar webhook",
"ar": "فشل في إعادة تثبيت الخطاف",
"fr": "Échec de la réinstallation du webhook",
"tr": "Webhook yeniden yüklenemedi",
"de": "Webhook konnte nicht neu installiert werden",
"uk": "Не вдалося перевстановити вебхук"
},
"BITBUCKET$TOKEN_LABEL": {
"en": "Bitbucket Token",
"ja": "Bitbucketトークン",

View File

@@ -6,11 +6,13 @@ import { BrandButton } from "#/components/features/settings/brand-button";
import { useLogout } from "#/hooks/mutation/use-logout";
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
import { GitLabWebhookManager } from "#/components/features/settings/git-settings/gitlab-webhook-manager";
import { BitbucketTokenInput } from "#/components/features/settings/git-settings/bitbucket-token-input";
import { AzureDevOpsTokenInput } from "#/components/features/settings/git-settings/azure-devops-token-input";
import { ForgejoTokenInput } from "#/components/features/settings/git-settings/forgejo-token-input";
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
import { InstallSlackAppAnchor } from "#/components/features/settings/git-settings/install-slack-app-anchor";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
@@ -21,6 +23,7 @@ import { GitSettingInputsSkeleton } from "#/components/features/settings/git-set
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
import { useUserProviders } from "#/hooks/use-user-providers";
import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration";
import { Typography } from "#/ui/typography";
function GitSettingsScreen() {
const { t } = useTranslation();
@@ -182,6 +185,33 @@ function GitSettingsScreen() {
</>
)}
{shouldRenderExternalConfigureButtons && !isLoading && (
<>
<div className="mt-6 flex flex-col gap-4 pb-8">
<Typography.H3 className="text-xl">
{t(I18nKey.SETTINGS$GITLAB)}
</Typography.H3>
<div className="flex items-center">
<DebugStackframeDot
className="w-6 h-6 shrink-0"
color={isGitLabTokenSet ? "#BCFF8C" : "#FF684E"}
/>
<Typography.Text
className="text-sm text-gray-400"
testId="gitlab-status-text"
>
{t(I18nKey.COMMON$STATUS)}:{" "}
{isGitLabTokenSet
? t(I18nKey.STATUS$CONNECTED)
: t(I18nKey.SETTINGS$GITLAB_NOT_CONNECTED)}
</Typography.Text>
</div>
{isGitLabTokenSet && <GitLabWebhookManager />}
</div>
<div className="w-1/2 border-b border-gray-200" />
</>
)}
{shouldRenderExternalConfigureButtons && !isLoading && (
<>
<div className="pb-1 mt-6 flex flex-col">