*/ public static function copyableAreas(): array { return [ self::COPY_PROJECT_SETTINGS, self::COPY_BOARD_WORKFLOW, self::COPY_TICKET_TYPES, self::COPY_TICKET_PRIORITIES, self::COPY_TICKET_FIELDS, self::COPY_TICKET_LINK_TYPES, self::COPY_REPOSITORY_CONNECTIONS, self::COPY_NOTIFICATION_ACTIONS, ]; } /** * @return list */ public static function copyAreaOptions(): array { return [ [ 'value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'Copy general project preferences like story points and author visibility.', ], [ 'value' => self::COPY_BOARD_WORKFLOW, 'label' => 'Board workflow', 'description' => 'Copy board columns, ticket statuses, and allowed status transitions.', ], [ 'value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Copy available ticket types, including parent-ticket types.', ], [ 'value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Copy the project-specific ticket priorities.', ], [ 'value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Copy custom ticket field definitions without any ticket values.', ], [ 'value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Copy available relationship labels between tickets.', ], [ 'value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Copy linked repositories without changing the new project identity.', ], [ 'value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Copy notification rules and delivery settings.', ], ]; } /** * @param list $copyAreas * @return list */ public function normalizeCopyAreas(array $copyAreas): array { return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas))); } /** * @param list $copyAreas * @return array */ public function export(Project $sourceProject, array $copyAreas): array { $selectedAreas = $this->normalizeCopyAreas($copyAreas); if ($selectedAreas === []) { return []; } $sourceProject->loadMissing([ 'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions', 'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes', ]); $configuration = []; if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) { $configuration[self::COPY_PROJECT_SETTINGS] = [ 'show_author' => $sourceProject->show_author, 'show_story_points' => $sourceProject->show_story_points, ]; } if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) { $boardColumnIndexes = []; $boardColumns = []; foreach ($sourceProject->boardColumns as $index => $boardColumn) { $boardColumnIndexes[$boardColumn->id] = $index; $boardColumns[] = [ 'name' => $boardColumn->name, 'position' => $boardColumn->position, ]; } $ticketStatusIndexes = []; $ticketStatuses = []; foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { $ticketStatusIndexes[$ticketStatus->id] = $index; $ticketStatuses[] = [ 'board_column_index' => $ticketStatus->board_column_id === null ? null : ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null), 'name' => $ticketStatus->name, 'position' => $ticketStatus->position, 'allowed_target_indexes' => [], ]; } foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) { $ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses ->pluck('id') ->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null) ->filter(static fn (?int $statusIndex): bool => $statusIndex !== null) ->values() ->all(); } $configuration[self::COPY_BOARD_WORKFLOW] = [ 'board_columns' => $boardColumns, 'ticket_statuses' => $ticketStatuses, ]; } if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) { $configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes ->map(fn ($ticketType): array => [ 'name' => $ticketType->name, 'position' => $ticketType->position, 'is_parent' => $ticketType->is_parent, ]) ->values() ->all(); } if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) { $configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities ->map(fn ($ticketPriority): array => [ 'name' => $ticketPriority->name, 'position' => $ticketPriority->position, ]) ->values() ->all(); } if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) { $configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions ->map(fn ($ticketFieldDefinition): array => [ 'field_type' => $ticketFieldDefinition->field_type, 'label' => $ticketFieldDefinition->label, 'position' => $ticketFieldDefinition->position, ]) ->values() ->all(); } if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) { $configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes ->map(fn ($ticketLinkType): array => [ 'label' => $ticketLinkType->label, 'counterpart_label' => $ticketLinkType->counterpart_label, 'position' => $ticketLinkType->position, ]) ->values() ->all(); } if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) { $configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections ->map(fn ($repositoryConnection): array => [ 'provider' => $repositoryConnection->provider->value, 'base_url' => $repositoryConnection->base_url, 'provider_repository_id' => $repositoryConnection->provider_repository_id, 'owner_name' => $repositoryConnection->owner_name, 'repository_name' => $repositoryConnection->repository_name, 'repository_full_name' => $repositoryConnection->repository_full_name, 'repository_url' => $repositoryConnection->repository_url, ]) ->values() ->all(); } if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) { $configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions ->map(fn ($notificationAction): array => [ 'name' => $notificationAction->name, 'enabled' => $notificationAction->enabled, 'event_type' => $notificationAction->event_type->value, 'channel' => $notificationAction->channel->value, 'channel_configuration' => $notificationAction->channel_configuration ?? [], 'message_template' => $notificationAction->message_template, 'delivery_url' => $notificationAction->delivery_url, 'filters' => $notificationAction->filters ?? [], ]) ->values() ->all(); } return $configuration; } /** * @param array $configuration */ public function apply(Project $targetProject, array $configuration): void { if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) { $projectSettings = $configuration[self::COPY_PROJECT_SETTINGS]; $targetProject->forceFill([ 'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author), 'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points), ])->save(); } if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) { $targetProject->ticketStatuses()->delete(); $targetProject->boardColumns()->delete(); $boardColumnIds = []; foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) { $newBoardColumn = $targetProject->boardColumns()->create([ 'name' => $boardColumn['name'], 'position' => $boardColumn['position'], ]); $boardColumnIds[] = $newBoardColumn->id; } $ticketStatusIds = []; foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) { $boardColumnIndex = $ticketStatus['board_column_index'] ?? null; $newTicketStatus = $targetProject->ticketStatuses()->create([ 'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null), 'name' => $ticketStatus['name'], 'position' => $ticketStatus['position'], ]); $ticketStatusIds[] = $newTicketStatus->id; } foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) { $newTicketStatusId = $ticketStatusIds[$index] ?? null; if ($newTicketStatusId === null) { continue; } $targetProject->ticketStatuses()->findOrFail($newTicketStatusId) ->allowedTargetStatuses() ->sync( collect($ticketStatus['allowed_target_indexes'] ?? []) ->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null) ->filter(static fn (?int $statusId): bool => $statusId !== null) ->values() ->all(), ); } } if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) { $targetProject->ticketTypes()->delete(); foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) { $targetProject->ticketTypes()->create([ 'name' => $ticketType['name'], 'position' => $ticketType['position'], 'is_parent' => $ticketType['is_parent'], ]); } } if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) { $targetProject->ticketPriorities()->delete(); foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) { $targetProject->ticketPriorities()->create([ 'name' => $ticketPriority['name'], 'position' => $ticketPriority['position'], ]); } } if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) { $targetProject->ticketFieldDefinitions()->delete(); foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) { $targetProject->ticketFieldDefinitions()->create([ 'field_type' => $ticketFieldDefinition['field_type'], 'label' => $ticketFieldDefinition['label'], 'position' => $ticketFieldDefinition['position'], ]); } } if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) { $targetProject->ticketLinkTypes()->delete(); foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) { $targetProject->ticketLinkTypes()->create([ 'label' => $ticketLinkType['label'], 'counterpart_label' => $ticketLinkType['counterpart_label'], 'position' => $ticketLinkType['position'], ]); } } if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) { $targetProject->repositoryConnections()->delete(); foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) { $targetProject->repositoryConnections()->create([ 'provider' => $repositoryConnection['provider'], 'base_url' => $repositoryConnection['base_url'], 'provider_repository_id' => $repositoryConnection['provider_repository_id'], 'owner_name' => $repositoryConnection['owner_name'], 'repository_name' => $repositoryConnection['repository_name'], 'repository_full_name' => $repositoryConnection['repository_full_name'], 'repository_url' => $repositoryConnection['repository_url'], ]); } } if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) { $targetProject->notificationActions()->delete(); foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) { $targetProject->notificationActions()->create([ 'name' => $notificationAction['name'], 'enabled' => $notificationAction['enabled'], 'event_type' => $notificationAction['event_type'], 'channel' => $notificationAction['channel'], 'channel_configuration' => $notificationAction['channel_configuration'] ?? [], 'message_template' => $notificationAction['message_template'] ?? null, 'delivery_url' => $notificationAction['delivery_url'], 'filters' => $notificationAction['filters'] ?? [], ]); } } } /** * @param list $copyAreas */ public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void { $this->apply($targetProject, $this->export($sourceProject, $copyAreas)); } }