From 3b17c9ee146698a7c2d88986c031771dac492847 Mon Sep 17 00:00:00 2001 From: Koda Date: Sun, 24 May 2026 22:36:21 +0000 Subject: [PATCH] Add project duplication flow --- app/Actions/Projects/CreateProject.php | 25 +- .../DuplicateProjectConfiguration.php | 250 ++++++++++++++++++ app/Http/Controllers/ProjectController.php | 8 +- app/Http/Requests/StoreProjectRequest.php | 52 ++++ resources/js/pages/projects/Create.vue | 166 ++++++++++-- resources/js/types/page-props.ts | 11 + tests/Feature/Api/ProjectApiTest.php | 84 ++++++ .../Projects/ProjectControllerTest.php | 168 +++++++++++- 8 files changed, 740 insertions(+), 24 deletions(-) create mode 100644 app/Actions/Projects/DuplicateProjectConfiguration.php diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php index 4baf52a..e9a53a8 100644 --- a/app/Actions/Projects/CreateProject.php +++ b/app/Actions/Projects/CreateProject.php @@ -7,24 +7,39 @@ use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Enums\Permission; use App\Models\Project; use App\Models\User; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; class CreateProject { public function __construct( + private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly PermissionService $permissionService, ) {} /** - * @param array{key: string, name: string} $attributes + * @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, copy_areas?: array|null} $attributes */ public function handle(array $attributes, User $actor): Project { - $project = Project::query()->create($attributes); + return DB::transaction(function () use ($attributes, $actor): Project { + $project = Project::query()->create(Arr::only($attributes, ['key', 'name'])); - $this->initializeProjectTicketMetadata->handle($project); - $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); + $this->initializeProjectTicketMetadata->handle($project); + $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); - return $project; + if (($attributes['creation_mode'] ?? 'scratch') === 'duplicate' && isset($attributes['source_project_id'])) { + $sourceProject = Project::query()->findOrFail((int) $attributes['source_project_id']); + + $this->duplicateProjectConfiguration->handle( + $sourceProject, + $project, + array_values($attributes['copy_areas'] ?? []), + ); + } + + return $project->fresh() ?? $project; + }); } } diff --git a/app/Actions/Projects/DuplicateProjectConfiguration.php b/app/Actions/Projects/DuplicateProjectConfiguration.php new file mode 100644 index 0000000..54c0eb1 --- /dev/null +++ b/app/Actions/Projects/DuplicateProjectConfiguration.php @@ -0,0 +1,250 @@ + + */ + 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 + */ + public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void + { + $selectedAreas = array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas))); + + if ($selectedAreas === []) { + return; + } + + $sourceProject->loadMissing([ + 'boardColumns', + 'notificationActions', + 'repositoryConnections', + 'ticketFieldDefinitions', + 'ticketLinkTypes', + 'ticketPriorities', + 'ticketStatuses.allowedTargetStatuses', + 'ticketTypes', + ]); + + if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) { + $targetProject->forceFill([ + 'show_author' => $sourceProject->show_author, + 'show_story_points' => $sourceProject->show_story_points, + ])->save(); + } + + if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) { + $targetProject->ticketStatuses()->delete(); + $targetProject->boardColumns()->delete(); + + $boardColumnIdMap = []; + + foreach ($sourceProject->boardColumns as $boardColumn) { + $newBoardColumn = $targetProject->boardColumns()->create([ + 'name' => $boardColumn->name, + 'position' => $boardColumn->position, + ]); + + $boardColumnIdMap[$boardColumn->id] = $newBoardColumn->id; + } + + $ticketStatusIdMap = []; + + foreach ($sourceProject->ticketStatuses as $ticketStatus) { + $newTicketStatus = $targetProject->ticketStatuses()->create([ + 'board_column_id' => $ticketStatus->board_column_id === null + ? null + : ($boardColumnIdMap[$ticketStatus->board_column_id] ?? null), + 'name' => $ticketStatus->name, + 'position' => $ticketStatus->position, + ]); + + $ticketStatusIdMap[$ticketStatus->id] = $newTicketStatus->id; + } + + foreach ($sourceProject->ticketStatuses as $ticketStatus) { + $newTicketStatusId = $ticketStatusIdMap[$ticketStatus->id] ?? null; + + if ($newTicketStatusId === null) { + continue; + } + + $targetProject->ticketStatuses() + ->findOrFail($newTicketStatusId) + ->allowedTargetStatuses() + ->sync( + $ticketStatus->allowedTargetStatuses + ->pluck('id') + ->map(fn (int $statusId): ?int => $ticketStatusIdMap[$statusId] ?? null) + ->filter() + ->values() + ->all(), + ); + } + } + + if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) { + $targetProject->ticketTypes()->delete(); + + foreach ($sourceProject->ticketTypes as $ticketType) { + $targetProject->ticketTypes()->create([ + 'name' => $ticketType->name, + 'position' => $ticketType->position, + 'is_parent' => $ticketType->is_parent, + ]); + } + } + + if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) { + $targetProject->ticketPriorities()->delete(); + + foreach ($sourceProject->ticketPriorities as $ticketPriority) { + $targetProject->ticketPriorities()->create([ + 'name' => $ticketPriority->name, + 'position' => $ticketPriority->position, + ]); + } + } + + if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) { + $targetProject->ticketFieldDefinitions()->delete(); + + foreach ($sourceProject->ticketFieldDefinitions as $ticketFieldDefinition) { + $targetProject->ticketFieldDefinitions()->create([ + 'field_type' => $ticketFieldDefinition->field_type, + 'label' => $ticketFieldDefinition->label, + 'position' => $ticketFieldDefinition->position, + ]); + } + } + + if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) { + $targetProject->ticketLinkTypes()->delete(); + + foreach ($sourceProject->ticketLinkTypes as $ticketLinkType) { + $targetProject->ticketLinkTypes()->create([ + 'label' => $ticketLinkType->label, + 'counterpart_label' => $ticketLinkType->counterpart_label, + 'position' => $ticketLinkType->position, + ]); + } + } + + if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) { + $targetProject->repositoryConnections()->delete(); + + foreach ($sourceProject->repositoryConnections 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 (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) { + $targetProject->notificationActions()->delete(); + + foreach ($sourceProject->notificationActions 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, + 'delivery_url' => $notificationAction->delivery_url, + 'filters' => $notificationAction->filters, + ]); + } + } + } +} diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index d6405bb..d5ffb43 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -6,6 +6,7 @@ use App\Actions\Permissions\PermissionService; use App\Actions\Projects\BuildProjectBacklogPayload; use App\Actions\Projects\CreateProject; use App\Actions\Projects\DeleteProject; +use App\Actions\Projects\DuplicateProjectConfiguration; use App\Actions\Projects\ListVisibleProjects; use App\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; @@ -51,7 +52,12 @@ class ProjectController extends Controller 403, ); - return Inertia::render('projects/Create'); + return Inertia::render('projects/Create', [ + 'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(), + 'sourceProjects' => ProjectResource::collection( + $this->listVisibleProjects->handle($request->user()), + )->resolve(), + ]); } public function store(StoreProjectRequest $request): RedirectResponse diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php index 6c7eeb0..8e1da6b 100644 --- a/app/Http/Requests/StoreProjectRequest.php +++ b/app/Http/Requests/StoreProjectRequest.php @@ -2,6 +2,10 @@ namespace App\Http\Requests; +use App\Actions\Permissions\PermissionService; +use App\Actions\Projects\DuplicateProjectConfiguration; +use App\Enums\Permission; +use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Arr; @@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest protected function prepareForValidation(): void { $key = Arr::get($this->all(), 'key'); + $creationMode = Arr::get($this->all(), 'creation_mode', 'scratch'); + $copyAreas = Arr::get($this->all(), 'copy_areas', []); if ($key !== null) { $this->merge([ 'key' => strtoupper(trim((string) $key)), ]); } + + $this->merge([ + 'creation_mode' => is_string($creationMode) ? trim($creationMode) : 'scratch', + 'copy_areas' => is_array($copyAreas) + ? array_values($copyAreas) + : ($copyAreas === null || $copyAreas === '' ? [] : [(string) $copyAreas]), + ]); } /** @@ -36,6 +49,7 @@ class StoreProjectRequest extends FormRequest public function rules(): array { return [ + 'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate'])], 'key' => [ 'required', 'string', @@ -44,6 +58,44 @@ class StoreProjectRequest extends FormRequest Rule::unique('projects', 'key'), ], 'name' => ['required', 'string', 'max:255'], + 'source_project_id' => [ + Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'duplicate'), + 'nullable', + 'integer', + Rule::exists('projects', 'id'), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === null || $value === '') { + return; + } + + $project = Project::query()->find($value); + + if ($project === null) { + return; + } + + $permissionService = app(PermissionService::class); + + if (! $permissionService->allows($this->user(), Permission::ProjectRead, $project)) { + $fail('Select a project you can access as the duplication source.'); + } + }, + ], + 'copy_areas' => ['nullable', 'array'], + 'copy_areas.*' => [ + 'string', + Rule::in(DuplicateProjectConfiguration::copyableAreas()), + ], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'source_project_id.required' => 'Select a source project to duplicate from.', ]; } } diff --git a/resources/js/pages/projects/Create.vue b/resources/js/pages/projects/Create.vue index a6c8517..926222e 100644 --- a/resources/js/pages/projects/Create.vue +++ b/resources/js/pages/projects/Create.vue @@ -1,5 +1,6 @@