diff --git a/enterprise/integrations/gitlab/gitlab_service.py b/enterprise/integrations/gitlab/gitlab_service.py index 6bf3db9835..0f7f9560b2 100644 --- a/enterprise/integrations/gitlab/gitlab_service.py +++ b/enterprise/integrations/gitlab/gitlab_service.py @@ -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 diff --git a/enterprise/integrations/gitlab/webhook_installation.py b/enterprise/integrations/gitlab/webhook_installation.py new file mode 100644 index 0000000000..c3ab74b205 --- /dev/null +++ b/enterprise/integrations/gitlab/webhook_installation.py @@ -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 diff --git a/enterprise/server/routes/integration/gitlab.py b/enterprise/server/routes/integration/gitlab.py index e7cad2cb22..a67c0c9742 100644 --- a/enterprise/server/routes/integration/gitlab.py +++ b/enterprise/server/routes/integration/gitlab.py @@ -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', + ) diff --git a/enterprise/storage/gitlab_webhook_store.py b/enterprise/storage/gitlab_webhook_store.py index 22d660fc0f..058e35c21b 100644 --- a/enterprise/storage/gitlab_webhook_store.py +++ b/enterprise/storage/gitlab_webhook_store.py @@ -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. diff --git a/enterprise/sync/install_gitlab_webhooks.py b/enterprise/sync/install_gitlab_webhooks.py index 8f274bf81c..e11085e300 100644 --- a/enterprise/sync/install_gitlab_webhooks.py +++ b/enterprise/sync/install_gitlab_webhooks.py @@ -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 diff --git a/enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py b/enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py new file mode 100644 index 0000000000..1f479f9312 --- /dev/null +++ b/enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py @@ -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': '; 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': '; 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 diff --git a/enterprise/tests/unit/server/routes/test_gitlab_integration_routes.py b/enterprise/tests/unit/server/routes/test_gitlab_integration_routes.py new file mode 100644 index 0000000000..43a0ebd013 --- /dev/null +++ b/enterprise/tests/unit/server/routes/test_gitlab_integration_routes.py @@ -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 + ) diff --git a/enterprise/tests/unit/storage/test_gitlab_webhook_store.py b/enterprise/tests/unit/storage/test_gitlab_webhook_store.py new file mode 100644 index 0000000000..56f55203a2 --- /dev/null +++ b/enterprise/tests/unit/storage/test_gitlab_webhook_store.py @@ -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 diff --git a/enterprise/tests/unit/sync/test_install_gitlab_webhooks.py b/enterprise/tests/unit/sync/test_install_gitlab_webhooks.py new file mode 100644 index 0000000000..3d8d91a965 --- /dev/null +++ b/enterprise/tests/unit/sync/test_install_gitlab_webhooks.py @@ -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 diff --git a/frontend/__tests__/components/features/settings/gitlab-webhook-manager-state.test.tsx b/frontend/__tests__/components/features/settings/gitlab-webhook-manager-state.test.tsx new file mode 100644 index 0000000000..b854b0cd7a --- /dev/null +++ b/frontend/__tests__/components/features/settings/gitlab-webhook-manager-state.test.tsx @@ -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(); + + // 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(); + + // Assert + const containerElement = container.firstChild as HTMLElement; + expect(containerElement).toHaveClass(customClassName); + }); +}); diff --git a/frontend/__tests__/components/features/settings/gitlab-webhook-manager.test.tsx b/frontend/__tests__/components/features/settings/gitlab-webhook-manager.test.tsx new file mode 100644 index 0000000000..12e5e72970 --- /dev/null +++ b/frontend/__tests__/components/features/settings/gitlab-webhook-manager.test.tsx @@ -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( + + + , + ); + }; + + 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( + (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); + }); + }); +}); diff --git a/frontend/__tests__/components/features/settings/webhook-status-badge.test.tsx b/frontend/__tests__/components/features/settings/webhook-status-badge.test.tsx new file mode 100644 index 0000000000..361c0d2590 --- /dev/null +++ b/frontend/__tests__/components/features/settings/webhook-status-badge.test.tsx @@ -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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // Assert + const badgeContainer = screen.getByText( + "GITLAB$WEBHOOK_STATUS_FAILED", + ).parentElement; + expect(badgeContainer).toHaveAttribute("title", errorMessage); + }); +}); diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index 6c0875f5a9..200e08bcb1 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -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(); + }); + }); +}); diff --git a/frontend/src/api/integration-service/integration-service.api.ts b/frontend/src/api/integration-service/integration-service.api.ts new file mode 100644 index 0000000000..38e97ffaf8 --- /dev/null +++ b/frontend/src/api/integration-service/integration-service.api.ts @@ -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 => { + const { data } = await openHands.get( + "/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 => { + const requestBody: ReinstallWebhookRequest = { resource }; + const { data } = await openHands.post( + "/integration/gitlab/reinstall-webhook", + requestBody, + ); + return data; + }, +}; diff --git a/frontend/src/api/integration-service/integration-service.types.ts b/frontend/src/api/integration-service/integration-service.types.ts new file mode 100644 index 0000000000..7d8c6ffde0 --- /dev/null +++ b/frontend/src/api/integration-service/integration-service.types.ts @@ -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; +} diff --git a/frontend/src/components/features/settings/git-settings/gitlab-webhook-manager-state.tsx b/frontend/src/components/features/settings/git-settings/gitlab-webhook-manager-state.tsx new file mode 100644 index 0000000000..1ea105769e --- /dev/null +++ b/frontend/src/components/features/settings/git-settings/gitlab-webhook-manager-state.tsx @@ -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 ( +
+ + {t(titleKey)} + + + {t(messageKey)} + +
+ ); +} diff --git a/frontend/src/components/features/settings/git-settings/gitlab-webhook-manager.tsx b/frontend/src/components/features/settings/git-settings/gitlab-webhook-manager.tsx new file mode 100644 index 0000000000..38749645a8 --- /dev/null +++ b/frontend/src/components/features/settings/git-settings/gitlab-webhook-manager.tsx @@ -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( + null, + ); + const [installationResults, setInstallationResults] = useState< + Map + >(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 ( + + ); + } + + if (isError) { + return ( + + ); + } + + if (resources.length === 0) { + return ( + + ); + } + + return ( +
+
+ + {t(I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE)} + +
+ + + {t(I18nKey.GITLAB$WEBHOOK_MANAGER_DESCRIPTION)} + + +
+ + + + + + + + + + + {resources.map((resource) => { + const key = getResourceKey(resource); + const result = installationResults.get(key); + const isInstalling = installingResource === key; + + return ( + + + + + + + ); + })} + +
+ {t(I18nKey.GITLAB$WEBHOOK_COLUMN_RESOURCE)} + + {t(I18nKey.GITLAB$WEBHOOK_COLUMN_TYPE)} + + {t(I18nKey.GITLAB$WEBHOOK_COLUMN_STATUS)} + + {t(I18nKey.GITLAB$WEBHOOK_COLUMN_ACTION)} +
+
+ + {resource.name} + + + {resource.full_path} + +
+
+ + {resource.type} + + +
+ + {result?.error && ( + + {result.error} + + )} +
+
+ 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)} + +
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/git-settings/webhook-status-badge.tsx b/frontend/src/components/features/settings/git-settings/webhook-status-badge.tsx new file mode 100644 index 0000000000..7855408d71 --- /dev/null +++ b/frontend/src/components/features/settings/git-settings/webhook-status-badge.tsx @@ -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 ( + + {t(I18nKey.GITLAB$WEBHOOK_STATUS_INSTALLED)} + + ); + } + return ( + + + {t(I18nKey.GITLAB$WEBHOOK_STATUS_FAILED)} + + + ); + } + + if (webhookInstalled) { + return ( + + {t(I18nKey.GITLAB$WEBHOOK_STATUS_INSTALLED)} + + ); + } + + return ( + + {t(I18nKey.GITLAB$WEBHOOK_STATUS_NOT_INSTALLED)} + + ); +} diff --git a/frontend/src/hooks/mutation/use-reinstall-gitlab-webhook.ts b/frontend/src/hooks/mutation/use-reinstall-gitlab-webhook.ts new file mode 100644 index 0000000000..af0f2b9afd --- /dev/null +++ b/frontend/src/hooks/mutation/use-reinstall-gitlab-webhook.ts @@ -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); + }, + }); +} diff --git a/frontend/src/hooks/query/use-gitlab-resources-list.ts b/frontend/src/hooks/query/use-gitlab-resources-list.ts new file mode 100644 index 0000000000..7e529bdae7 --- /dev/null +++ b/frontend/src/hooks/query/use-gitlab-resources-list.ts @@ -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({ + queryKey: ["gitlab-resources"], + queryFn: () => integrationService.getGitLabResources(), + enabled, + staleTime: 1000 * 60 * 2, // 2 minutes + gcTime: 1000 * 60 * 10, // 10 minutes + }); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index a3c04ce323..30645cb4f7 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index b2aaeb1a1d..bfad5665e4 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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トークン", diff --git a/frontend/src/routes/git-settings.tsx b/frontend/src/routes/git-settings.tsx index ca43591117..00b274f1de 100644 --- a/frontend/src/routes/git-settings.tsx +++ b/frontend/src/routes/git-settings.tsx @@ -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 && ( + <> +
+ + {t(I18nKey.SETTINGS$GITLAB)} + +
+ + + {t(I18nKey.COMMON$STATUS)}:{" "} + {isGitLabTokenSet + ? t(I18nKey.STATUS$CONNECTED) + : t(I18nKey.SETTINGS$GITLAB_NOT_CONNECTED)} + +
+ {isGitLabTokenSet && } +
+
+ + )} + {shouldRenderExternalConfigureButtons && !isLoading && ( <>