From 8b25421e7ce90bc5b6ab1001077e93be9201d25f Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 13:22:56 +0200 Subject: [PATCH 1/6] Add application API and OpenAPI docs --- .../ProjectBoard/DeleteBoardColumn.php | 36 + .../ProjectBoard/DeleteTicketStatus.php | 50 + .../ManagesTicketStatusPositions.php | 42 + .../ProjectBoard/ReordersBoardColumns.php | 54 + app/Actions/ProjectBoard/StoreBoardColumn.php | 37 + .../ProjectBoard/StoreTicketStatus.php | 32 + .../ProjectBoard/UpdateBoardColumn.php | 29 + .../ProjectBoard/UpdateTicketStatus.php | 21 + .../UpdateTicketStatusTransitions.php | 25 + .../DeleteProjectTicketPriority.php | 40 + .../DeleteProjectTicketType.php | 81 + .../DeleteTicketFieldDefinition.php | 25 + .../DeleteTicketLinkType.php | 25 + .../ReordersProjectMetadata.php | 41 + .../StoreProjectTicketPriority.php | 37 + .../StoreProjectTicketType.php | 35 + .../StoreTicketFieldDefinition.php | 35 + .../StoreTicketLinkType.php | 37 + .../UpdateProjectTicketMetadataSettings.php | 22 + .../UpdateProjectTicketPriority.php | 36 + .../UpdateProjectTicketType.php | 33 + .../UpdateTicketFieldDefinition.php | 34 + .../UpdateTicketLinkType.php | 36 + .../Projects/BuildProjectBacklogPayload.php | 67 + .../Projects/BuildProjectBoardPayload.php | 46 + .../BuildProjectBoardSettingsPayload.php | 37 + .../BuildProjectTicketDetailPayload.php | 124 + ...ldProjectTicketMetadataSettingsPayload.php | 32 + .../BuildProjectTicketOptionsPayload.php | 46 + ...BuildProjectTicketStatusTargetsPayload.php | 26 + app/Actions/Projects/CreateProject.php | 30 + app/Actions/Projects/DeleteProject.php | 17 + app/Actions/Projects/ListVisibleProjects.php | 39 + .../BuildProjectRepositorySettingsPayload.php | 120 + .../DeleteProjectRepositoryConnection.php | 17 + .../ResolveRepositorySelectionAttributes.php | 70 + .../StoreProjectRepositoryConnection.php | 59 + .../UpdateProjectRepositoryConnection.php | 67 + .../Tickets/CreateProjectTicketBranch.php | 104 + .../Tickets/DeleteProjectTicketLink.php | 60 + app/Actions/Tickets/MoveProjectTicket.php | 86 + .../Tickets/StoreProjectTicketLink.php | 82 + .../Tickets/ValidateProjectTicketBranch.php | 99 + .../DeleteUserGitConnection.php | 17 + .../StoreUserGitConnection.php | 50 + .../UpdateUserGitConnection.php | 50 + app/Http/Controllers/Api/ApiController.php | 19 + .../Api/ProjectBoardController.php | 48 + .../Api/ProjectBoardSettingsController.php | 167 ++ .../Controllers/Api/ProjectController.php | 68 + .../ProjectRepositorySettingsController.php | 105 + .../Api/ProjectTicketBranchController.php | 61 + .../Api/ProjectTicketController.php | 183 ++ .../Api/ProjectTicketLinkController.php | 67 + ...rojectTicketMetadataSettingsController.php | 254 ++ .../Api/Settings/GitConnectionController.php | 68 + .../Controllers/ProjectBoardController.php | 91 +- .../ProjectBoardSettingsController.php | 222 +- app/Http/Controllers/ProjectController.php | 83 +- .../ProjectRepositorySettingsController.php | 243 +- .../ProjectTicketBranchController.php | 166 +- .../Controllers/ProjectTicketController.php | 146 +- .../ProjectTicketLinkController.php | 107 +- ...rojectTicketMetadataSettingsController.php | 315 +- .../Settings/GitConnectionController.php | 72 +- .../Requests/Api/MoveProjectTicketRequest.php | 72 + bootstrap/app.php | 3 +- docs/openapi/kanbangit-api.openapi.json | 2602 +++++++++++++++++ routes/api.php | 5 + routes/api/project/settings.php | 34 + routes/api/project/settings/board.php | 16 + routes/api/project/settings/metadata.php | 27 + routes/api/project/settings/repositories.php | 7 + routes/api/project/tickets.php | 31 + routes/api/projects.php | 38 + routes/api/settings.php | 14 + .../Feature/Api/OpenApiDocumentationTest.php | 81 + tests/Feature/Api/ProjectApiTest.php | 87 + tests/Feature/Api/SettingsApiTest.php | 231 ++ tests/Feature/Api/TicketApiTest.php | 264 ++ 80 files changed, 6908 insertions(+), 1277 deletions(-) create mode 100644 app/Actions/ProjectBoard/DeleteBoardColumn.php create mode 100644 app/Actions/ProjectBoard/DeleteTicketStatus.php create mode 100644 app/Actions/ProjectBoard/ManagesTicketStatusPositions.php create mode 100644 app/Actions/ProjectBoard/ReordersBoardColumns.php create mode 100644 app/Actions/ProjectBoard/StoreBoardColumn.php create mode 100644 app/Actions/ProjectBoard/StoreTicketStatus.php create mode 100644 app/Actions/ProjectBoard/UpdateBoardColumn.php create mode 100644 app/Actions/ProjectBoard/UpdateTicketStatus.php create mode 100644 app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php create mode 100644 app/Actions/ProjectTicketMetadata/DeleteProjectTicketPriority.php create mode 100644 app/Actions/ProjectTicketMetadata/DeleteProjectTicketType.php create mode 100644 app/Actions/ProjectTicketMetadata/DeleteTicketFieldDefinition.php create mode 100644 app/Actions/ProjectTicketMetadata/DeleteTicketLinkType.php create mode 100644 app/Actions/ProjectTicketMetadata/ReordersProjectMetadata.php create mode 100644 app/Actions/ProjectTicketMetadata/StoreProjectTicketPriority.php create mode 100644 app/Actions/ProjectTicketMetadata/StoreProjectTicketType.php create mode 100644 app/Actions/ProjectTicketMetadata/StoreTicketFieldDefinition.php create mode 100644 app/Actions/ProjectTicketMetadata/StoreTicketLinkType.php create mode 100644 app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php create mode 100644 app/Actions/ProjectTicketMetadata/UpdateProjectTicketPriority.php create mode 100644 app/Actions/ProjectTicketMetadata/UpdateProjectTicketType.php create mode 100644 app/Actions/ProjectTicketMetadata/UpdateTicketFieldDefinition.php create mode 100644 app/Actions/ProjectTicketMetadata/UpdateTicketLinkType.php create mode 100644 app/Actions/Projects/BuildProjectBacklogPayload.php create mode 100644 app/Actions/Projects/BuildProjectBoardPayload.php create mode 100644 app/Actions/Projects/BuildProjectBoardSettingsPayload.php create mode 100644 app/Actions/Projects/BuildProjectTicketDetailPayload.php create mode 100644 app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php create mode 100644 app/Actions/Projects/BuildProjectTicketOptionsPayload.php create mode 100644 app/Actions/Projects/BuildProjectTicketStatusTargetsPayload.php create mode 100644 app/Actions/Projects/CreateProject.php create mode 100644 app/Actions/Projects/DeleteProject.php create mode 100644 app/Actions/Projects/ListVisibleProjects.php create mode 100644 app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php create mode 100644 app/Actions/Repositories/DeleteProjectRepositoryConnection.php create mode 100644 app/Actions/Repositories/ResolveRepositorySelectionAttributes.php create mode 100644 app/Actions/Repositories/StoreProjectRepositoryConnection.php create mode 100644 app/Actions/Repositories/UpdateProjectRepositoryConnection.php create mode 100644 app/Actions/Tickets/CreateProjectTicketBranch.php create mode 100644 app/Actions/Tickets/DeleteProjectTicketLink.php create mode 100644 app/Actions/Tickets/MoveProjectTicket.php create mode 100644 app/Actions/Tickets/StoreProjectTicketLink.php create mode 100644 app/Actions/Tickets/ValidateProjectTicketBranch.php create mode 100644 app/Actions/UserGitConnections/DeleteUserGitConnection.php create mode 100644 app/Actions/UserGitConnections/StoreUserGitConnection.php create mode 100644 app/Actions/UserGitConnections/UpdateUserGitConnection.php create mode 100644 app/Http/Controllers/Api/ApiController.php create mode 100644 app/Http/Controllers/Api/ProjectBoardController.php create mode 100644 app/Http/Controllers/Api/ProjectBoardSettingsController.php create mode 100644 app/Http/Controllers/Api/ProjectController.php create mode 100644 app/Http/Controllers/Api/ProjectRepositorySettingsController.php create mode 100644 app/Http/Controllers/Api/ProjectTicketBranchController.php create mode 100644 app/Http/Controllers/Api/ProjectTicketController.php create mode 100644 app/Http/Controllers/Api/ProjectTicketLinkController.php create mode 100644 app/Http/Controllers/Api/ProjectTicketMetadataSettingsController.php create mode 100644 app/Http/Controllers/Api/Settings/GitConnectionController.php create mode 100644 app/Http/Requests/Api/MoveProjectTicketRequest.php create mode 100644 docs/openapi/kanbangit-api.openapi.json create mode 100644 routes/api.php create mode 100644 routes/api/project/settings.php create mode 100644 routes/api/project/settings/board.php create mode 100644 routes/api/project/settings/metadata.php create mode 100644 routes/api/project/settings/repositories.php create mode 100644 routes/api/project/tickets.php create mode 100644 routes/api/projects.php create mode 100644 routes/api/settings.php create mode 100644 tests/Feature/Api/OpenApiDocumentationTest.php create mode 100644 tests/Feature/Api/ProjectApiTest.php create mode 100644 tests/Feature/Api/SettingsApiTest.php create mode 100644 tests/Feature/Api/TicketApiTest.php diff --git a/app/Actions/ProjectBoard/DeleteBoardColumn.php b/app/Actions/ProjectBoard/DeleteBoardColumn.php new file mode 100644 index 0000000..5c9915b --- /dev/null +++ b/app/Actions/ProjectBoard/DeleteBoardColumn.php @@ -0,0 +1,36 @@ +name; + + DB::transaction(function () use ($boardColumn, $project): void { + $boardColumn->ticketStatuses() + ->get() + ->each(function (TicketStatus $ticketStatus) use ($project): void { + $ticketStatus->update([ + 'board_column_id' => null, + 'position' => $this->nextStatusPosition($project, null), + ]); + }); + + $boardColumn->delete(); + + $this->normalizeColumnPositions($project); + }); + + return $deletedName; + } +} diff --git a/app/Actions/ProjectBoard/DeleteTicketStatus.php b/app/Actions/ProjectBoard/DeleteTicketStatus.php new file mode 100644 index 0000000..59088cb --- /dev/null +++ b/app/Actions/ProjectBoard/DeleteTicketStatus.php @@ -0,0 +1,50 @@ +load('boardColumn'); + $boardColumnId = $ticketStatus->board_column_id; + + if ($this->statusContextQuery($project, $boardColumnId)->count() <= 1) { + throw ValidationException::withMessages([ + 'ticket_status_id' => "{$this->statusContextLabel($ticketStatus)} needs at least one ticket status.", + ]); + } + + $deletedName = $ticketStatus->name; + $contextLabel = $this->statusContextLabel($ticketStatus); + + DB::transaction(function () use ($boardColumnId, $project, $ticketStatus): void { + $replacementStatus = $this->statusContextQuery($project, $boardColumnId) + ->whereKeyNot($ticketStatus->id) + ->firstOrFail(); + + $ticketStatus->tickets()->update([ + 'ticket_status_id' => $replacementStatus->id, + ]); + + $ticketStatus->delete(); + + $this->normalizeStatusPositions($project, $boardColumnId); + }); + + return [ + 'context_label' => $contextLabel, + 'deleted_name' => $deletedName, + ]; + } +} diff --git a/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php b/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php new file mode 100644 index 0000000..07493c9 --- /dev/null +++ b/app/Actions/ProjectBoard/ManagesTicketStatusPositions.php @@ -0,0 +1,42 @@ +statusContextQuery($project, $boardColumnId)->count() + 1; + } + + private function normalizeStatusPositions(Project $project, ?int $boardColumnId): void + { + $this->statusContextQuery($project, $boardColumnId) + ->get() + ->values() + ->each(function (TicketStatus $ticketStatus, int $index): void { + $ticketStatus->forceFill([ + 'position' => $index + 1, + ])->saveQuietly(); + }); + } + + private function statusContextLabel(TicketStatus $ticketStatus): string + { + return $ticketStatus->boardColumn?->name ?? 'Backlog'; + } + + private function statusContextQuery(Project $project, ?int $boardColumnId): HasMany + { + return $project->ticketStatuses() + ->when( + $boardColumnId === null, + fn ($query) => $query->whereNull('board_column_id'), + fn ($query) => $query->where('board_column_id', $boardColumnId), + ); + } +} diff --git a/app/Actions/ProjectBoard/ReordersBoardColumns.php b/app/Actions/ProjectBoard/ReordersBoardColumns.php new file mode 100644 index 0000000..0657fe5 --- /dev/null +++ b/app/Actions/ProjectBoard/ReordersBoardColumns.php @@ -0,0 +1,54 @@ +persistOrderedColumns( + $project->boardColumns() + ->get() + ->values(), + ); + } + + private function reorderColumns(Project $project, BoardColumn $movingColumn, int $desiredPosition): void + { + /** @var Collection $columns */ + $columns = $project->boardColumns() + ->whereKeyNot($movingColumn->id) + ->get() + ->values(); + + $insertAt = max(0, min($desiredPosition - 1, $columns->count())); + + $columns->splice($insertAt, 0, [$movingColumn]); + + $this->persistOrderedColumns($columns->values()); + } + + /** + * @param Collection $columns + */ + private function persistOrderedColumns(Collection $columns): void + { + $temporaryOffset = ((int) $columns->max('position')) + $columns->count() + 1; + + $columns->each(function (BoardColumn $column, int $index) use ($temporaryOffset): void { + $column->forceFill([ + 'position' => $temporaryOffset + $index, + ])->saveQuietly(); + }); + + $columns->each(function (BoardColumn $column, int $index): void { + $column->forceFill([ + 'position' => $index + 1, + ])->saveQuietly(); + }); + } +} diff --git a/app/Actions/ProjectBoard/StoreBoardColumn.php b/app/Actions/ProjectBoard/StoreBoardColumn.php new file mode 100644 index 0000000..fdc1497 --- /dev/null +++ b/app/Actions/ProjectBoard/StoreBoardColumn.php @@ -0,0 +1,37 @@ +boardColumns()->count() + 1; + + return DB::transaction(function () use ($project, $validated, $desiredPosition): BoardColumn { + $boardColumn = $project->boardColumns()->create([ + 'name' => Arr::get($validated, 'name'), + 'position' => $project->boardColumns()->count() + 1, + ]); + $boardColumn->ticketStatuses()->create([ + 'name' => Arr::get($validated, 'name'), + 'position' => 1, + 'project_id' => $project->id, + ]); + + $this->reorderColumns($project, $boardColumn, $desiredPosition); + + return $boardColumn; + }); + } +} diff --git a/app/Actions/ProjectBoard/StoreTicketStatus.php b/app/Actions/ProjectBoard/StoreTicketStatus.php new file mode 100644 index 0000000..7988df6 --- /dev/null +++ b/app/Actions/ProjectBoard/StoreTicketStatus.php @@ -0,0 +1,32 @@ +ticketStatuses()->create([ + 'board_column_id' => $boardColumnId, + 'name' => Arr::get($validated, 'name'), + 'position' => $this->nextStatusPosition($project, $boardColumnId), + ]); + }); + + return $ticketStatus->load('boardColumn'); + } +} diff --git a/app/Actions/ProjectBoard/UpdateBoardColumn.php b/app/Actions/ProjectBoard/UpdateBoardColumn.php new file mode 100644 index 0000000..7c7ae54 --- /dev/null +++ b/app/Actions/ProjectBoard/UpdateBoardColumn.php @@ -0,0 +1,29 @@ +update([ + 'name' => Arr::get($validated, 'name'), + ]); + + $this->reorderColumns($project, $boardColumn, Arr::get($validated, 'position')); + }); + + return $boardColumn->fresh(); + } +} diff --git a/app/Actions/ProjectBoard/UpdateTicketStatus.php b/app/Actions/ProjectBoard/UpdateTicketStatus.php new file mode 100644 index 0000000..a057300 --- /dev/null +++ b/app/Actions/ProjectBoard/UpdateTicketStatus.php @@ -0,0 +1,21 @@ +update([ + 'name' => Arr::get($validated, 'name'), + ]); + + return $ticketStatus->fresh(); + } +} diff --git a/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php new file mode 100644 index 0000000..a17fef0 --- /dev/null +++ b/app/Actions/ProjectBoard/UpdateTicketStatusTransitions.php @@ -0,0 +1,25 @@ +|null} $validated + */ + public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus + { + $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) + ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) + ->unique() + ->values() + ->all(); + + $ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds); + + return $ticketStatus->fresh(); + } +} diff --git a/app/Actions/ProjectTicketMetadata/DeleteProjectTicketPriority.php b/app/Actions/ProjectTicketMetadata/DeleteProjectTicketPriority.php new file mode 100644 index 0000000..0e32dc4 --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/DeleteProjectTicketPriority.php @@ -0,0 +1,40 @@ +ticketPriorities()->count() === 1) { + return [ + 'deleted' => false, + 'deleted_name' => $ticketPriority->name, + 'message' => 'The last ticket priority cannot be deleted.', + ]; + } + + $deletedName = $ticketPriority->name; + + DB::transaction(function () use ($project, $ticketPriority): void { + $ticketPriority->delete(); + + $this->persistOrderedModels($project->ticketPriorities()->get()->values()); + }); + + return [ + 'deleted' => true, + 'deleted_name' => $deletedName, + 'message' => "{$deletedName} priority was removed.", + ]; + } +} diff --git a/app/Actions/ProjectTicketMetadata/DeleteProjectTicketType.php b/app/Actions/ProjectTicketMetadata/DeleteProjectTicketType.php new file mode 100644 index 0000000..4b890fa --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/DeleteProjectTicketType.php @@ -0,0 +1,81 @@ +ticketTypes()->count() === 1) { + return [ + 'deleted' => false, + 'deleted_name' => $ticketType->name, + 'message' => 'The last ticket type cannot be deleted.', + ]; + } + + $deletedName = $ticketType->name; + + DB::transaction(function () use ($actor, $deletedName, $project, $ticketType, $validated): void { + $replacementTicketTypeId = Arr::get($validated, 'replacement_ticket_type_id'); + + if ($replacementTicketTypeId !== null) { + $replacementTicketType = $project->ticketTypes() + ->findOrFail($replacementTicketTypeId); + + Ticket::query() + ->where('project_id', $project->id) + ->where('ticket_type_id', $ticketType->id) + ->with(['ticketType']) + ->get() + ->each(function (Ticket $ticket) use ($actor, $deletedName, $replacementTicketType): void { + $ticket->update([ + 'ticket_type_id' => $replacementTicketType->id, + ]); + + $this->createTicketWorklogEntry->handle( + $ticket->fresh(), + $actor, + TicketWorklogEntryType::Updated, + "Updated type because {$deletedName} type was deleted.", + [ + 'ticket_type' => [ + 'from' => $deletedName, + 'to' => $replacementTicketType->name, + ], + ], + ); + }); + } + + $ticketType->delete(); + + $this->persistOrderedModels($project->ticketTypes()->get()->values()); + }); + + return [ + 'deleted' => true, + 'deleted_name' => $deletedName, + 'message' => "{$deletedName} type was removed.", + ]; + } +} diff --git a/app/Actions/ProjectTicketMetadata/DeleteTicketFieldDefinition.php b/app/Actions/ProjectTicketMetadata/DeleteTicketFieldDefinition.php new file mode 100644 index 0000000..02f13a0 --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/DeleteTicketFieldDefinition.php @@ -0,0 +1,25 @@ +label; + + DB::transaction(function () use ($project, $ticketFieldDefinition): void { + $ticketFieldDefinition->delete(); + + $this->persistOrderedModels($project->ticketFieldDefinitions()->get()->values()); + }); + + return $deletedLabel; + } +} diff --git a/app/Actions/ProjectTicketMetadata/DeleteTicketLinkType.php b/app/Actions/ProjectTicketMetadata/DeleteTicketLinkType.php new file mode 100644 index 0000000..45b5d45 --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/DeleteTicketLinkType.php @@ -0,0 +1,25 @@ +label; + + DB::transaction(function () use ($project, $ticketLinkType): void { + $ticketLinkType->delete(); + + $this->persistOrderedModels($project->ticketLinkTypes()->get()->values()); + }); + + return $deletedLabel; + } +} diff --git a/app/Actions/ProjectTicketMetadata/ReordersProjectMetadata.php b/app/Actions/ProjectTicketMetadata/ReordersProjectMetadata.php new file mode 100644 index 0000000..8429e63 --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/ReordersProjectMetadata.php @@ -0,0 +1,41 @@ + $models + */ + private function persistOrderedModels(Collection $models): void + { + $temporaryOffset = ((int) $models->max('position')) + $models->count() + 1; + + $models->each(function (Model $model, int $index) use ($temporaryOffset): void { + $model->forceFill([ + 'position' => $temporaryOffset + $index, + ])->saveQuietly(); + }); + + $models->each(function (Model $model, int $index): void { + $model->forceFill([ + 'position' => $index + 1, + ])->saveQuietly(); + }); + } + + /** + * @param Collection $existingModels + */ + private function reorderModels(Collection $existingModels, Model $movingModel, int $desiredPosition): void + { + $insertAt = max(0, min($desiredPosition - 1, $existingModels->count())); + + $existingModels->splice($insertAt, 0, [$movingModel]); + + $this->persistOrderedModels($existingModels->values()); + } +} diff --git a/app/Actions/ProjectTicketMetadata/StoreProjectTicketPriority.php b/app/Actions/ProjectTicketMetadata/StoreProjectTicketPriority.php new file mode 100644 index 0000000..7337e4a --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/StoreProjectTicketPriority.php @@ -0,0 +1,37 @@ +ticketPriorities()->create([ + 'name' => $name, + 'position' => $project->ticketPriorities()->count() + 1, + ]); + + $this->reorderModels( + $project->ticketPriorities()->whereKeyNot($ticketPriority->id)->get()->values(), + $ticketPriority, + $position ?? $project->ticketPriorities()->count(), + ); + + return $ticketPriority; + }); + } +} diff --git a/app/Actions/ProjectTicketMetadata/StoreProjectTicketType.php b/app/Actions/ProjectTicketMetadata/StoreProjectTicketType.php new file mode 100644 index 0000000..d568248 --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/StoreProjectTicketType.php @@ -0,0 +1,35 @@ +ticketTypes()->create([ + 'name' => Arr::get($validated, 'name'), + 'position' => $project->ticketTypes()->count() + 1, + 'is_parent' => Arr::get($validated, 'is_parent', false), + ]); + + $this->reorderModels( + $project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(), + $ticketType, + Arr::get($validated, 'position') ?? $project->ticketTypes()->count(), + ); + + return $ticketType; + }); + } +} diff --git a/app/Actions/ProjectTicketMetadata/StoreTicketFieldDefinition.php b/app/Actions/ProjectTicketMetadata/StoreTicketFieldDefinition.php new file mode 100644 index 0000000..6cb5bce --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/StoreTicketFieldDefinition.php @@ -0,0 +1,35 @@ +ticketFieldDefinitions()->create([ + 'field_type' => Arr::get($validated, 'field_type'), + 'label' => Arr::get($validated, 'label'), + 'position' => $project->ticketFieldDefinitions()->count() + 1, + ]); + + $this->reorderModels( + $project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(), + $ticketFieldDefinition, + Arr::get($validated, 'position') ?? $project->ticketFieldDefinitions()->count(), + ); + + return $ticketFieldDefinition; + }); + } +} diff --git a/app/Actions/ProjectTicketMetadata/StoreTicketLinkType.php b/app/Actions/ProjectTicketMetadata/StoreTicketLinkType.php new file mode 100644 index 0000000..24a4e52 --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/StoreTicketLinkType.php @@ -0,0 +1,37 @@ +ticketLinkTypes()->create([ + 'label' => $name, + 'position' => $project->ticketLinkTypes()->count() + 1, + ]); + + $this->reorderModels( + $project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(), + $ticketLinkType, + $position ?? $project->ticketLinkTypes()->count(), + ); + + return $ticketLinkType; + }); + } +} diff --git a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php new file mode 100644 index 0000000..5edc3ec --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketMetadataSettings.php @@ -0,0 +1,22 @@ +update([ + 'show_author' => Arr::get($validated, 'show_author', false), + 'show_story_points' => Arr::get($validated, 'show_story_points', false), + ]); + + return $project->fresh(); + } +} diff --git a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketPriority.php b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketPriority.php new file mode 100644 index 0000000..8563593 --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketPriority.php @@ -0,0 +1,36 @@ +update([ + 'name' => $name, + ]); + + $this->reorderModels( + $project->ticketPriorities()->whereKeyNot($ticketPriority->id)->get()->values(), + $ticketPriority, + $position, + ); + }); + + return $ticketPriority->fresh(); + } +} diff --git a/app/Actions/ProjectTicketMetadata/UpdateProjectTicketType.php b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketType.php new file mode 100644 index 0000000..6efec7b --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/UpdateProjectTicketType.php @@ -0,0 +1,33 @@ +update([ + 'name' => Arr::get($validated, 'name'), + ]); + + $this->reorderModels( + $project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(), + $ticketType, + Arr::get($validated, 'position'), + ); + }); + + return $ticketType->fresh(); + } +} diff --git a/app/Actions/ProjectTicketMetadata/UpdateTicketFieldDefinition.php b/app/Actions/ProjectTicketMetadata/UpdateTicketFieldDefinition.php new file mode 100644 index 0000000..8166f36 --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/UpdateTicketFieldDefinition.php @@ -0,0 +1,34 @@ +update([ + 'field_type' => Arr::get($validated, 'field_type'), + 'label' => Arr::get($validated, 'label'), + ]); + + $this->reorderModels( + $project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(), + $ticketFieldDefinition, + Arr::get($validated, 'position'), + ); + }); + + return $ticketFieldDefinition->fresh(); + } +} diff --git a/app/Actions/ProjectTicketMetadata/UpdateTicketLinkType.php b/app/Actions/ProjectTicketMetadata/UpdateTicketLinkType.php new file mode 100644 index 0000000..2ff245c --- /dev/null +++ b/app/Actions/ProjectTicketMetadata/UpdateTicketLinkType.php @@ -0,0 +1,36 @@ +update([ + 'label' => $name, + ]); + + $this->reorderModels( + $project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(), + $ticketLinkType, + $position, + ); + }); + + return $ticketLinkType->fresh(); + } +} diff --git a/app/Actions/Projects/BuildProjectBacklogPayload.php b/app/Actions/Projects/BuildProjectBacklogPayload.php new file mode 100644 index 0000000..eb71b01 --- /dev/null +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -0,0 +1,67 @@ + + */ + public function handle(Project $project, User $user, mixed $status = null): array + { + $canManageTickets = $this->permissionService->allows($user, Permission::ProjectWrite, $project); + + return [ + 'backlogTicketStatuses' => TicketStatusResource::collection( + $project->ticketStatuses() + ->whereNull('board_column_id') + ->get(), + )->resolve(), + 'canCreateTickets' => $canManageTickets, + 'canEditTickets' => $canManageTickets, + 'project' => ProjectResource::make($project)->resolve(), + 'status' => $status, + 'ticketStatusOptions' => TicketStatusResource::collection( + $project->ticketStatuses() + ->with('boardColumn') + ->get(), + )->resolve(), + 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), + 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), + 'tickets' => TicketResource::collection( + Ticket::query() + ->with([ + 'ticketPriority', + 'ticketStatus.allowedTargetStatuses.boardColumn', + 'ticketStatus.boardColumn', + 'ticketType', + ]) + ->where('project_id', $project->id) + ->whereHas('ticketStatus', fn ($query) => $query->whereNull('board_column_id')) + ->orderByDesc('sequence_number') + ->get(), + )->resolve(), + 'users' => UserResource::collection( + User::query() + ->orderBy('name') + ->get(), + )->resolve(), + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectBoardPayload.php b/app/Actions/Projects/BuildProjectBoardPayload.php new file mode 100644 index 0000000..1afb503 --- /dev/null +++ b/app/Actions/Projects/BuildProjectBoardPayload.php @@ -0,0 +1,46 @@ + + */ + public function handle(Project $project, User $user, mixed $status = null): array + { + $canManageTickets = $this->permissionService->allows($user, Permission::ProjectWrite, $project); + + return [ + 'boardColumns' => BoardColumnResource::collection( + $project->boardColumns() + ->with([ + 'ticketStatuses', + 'tickets' => fn ($query) => $query + ->with([ + 'ticketPriority', + 'ticketStatus.allowedTargetStatuses.boardColumn', + 'ticketStatus.boardColumn', + 'ticketType', + ]), + ]) + ->get(), + )->resolve(), + 'canCreateTickets' => $canManageTickets, + 'canEditTickets' => $canManageTickets, + 'project' => ProjectResource::make($project)->resolve(), + 'status' => $status, + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectBoardSettingsPayload.php b/app/Actions/Projects/BuildProjectBoardSettingsPayload.php new file mode 100644 index 0000000..a1992e6 --- /dev/null +++ b/app/Actions/Projects/BuildProjectBoardSettingsPayload.php @@ -0,0 +1,37 @@ + + */ + public function handle(Project $project, mixed $status = null): array + { + return [ + 'backlogTicketStatuses' => TicketStatusResource::collection( + $project->ticketStatuses() + ->whereNull('board_column_id') + ->get(), + )->resolve(), + 'boardColumns' => BoardColumnResource::collection( + $project->boardColumns() + ->with('ticketStatuses') + ->get(), + )->resolve(), + 'project' => ProjectResource::make($project)->resolve(), + 'status' => $status, + 'ticketStatuses' => TicketStatusResource::collection( + $project->ticketStatuses() + ->with(['allowedTargetStatuses.boardColumn', 'boardColumn']) + ->get(), + )->resolve(), + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectTicketDetailPayload.php b/app/Actions/Projects/BuildProjectTicketDetailPayload.php new file mode 100644 index 0000000..26b4511 --- /dev/null +++ b/app/Actions/Projects/BuildProjectTicketDetailPayload.php @@ -0,0 +1,124 @@ + + */ + public function handle(Project $project, Ticket $ticket, User $user, mixed $status = null): array + { + $canEditTicket = $this->permissionService->allows($user, Permission::ProjectWrite, $project); + $canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project); + $displayTicket = $ticket->load([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'parentTicket', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + 'worklogEntries.actor', + ]); + + if (! $project->show_author && ! $canManageProject) { + $displayTicket->setRelation('author', null); + $displayTicket->author_user_id = null; + } + + return [ + 'canEditTicket' => $canEditTicket, + 'canManageProject' => $canManageProject, + 'childTicketTree' => TicketTreeNodeResource::collection( + $ticket->childTickets() + ->with('ticketType') + ->get(), + )->resolve(), + 'linkedTickets' => TicketLinkResource::collection( + TicketLink::query() + ->with(['sourceTicket', 'targetTicket', 'ticketLinkType']) + ->where(function ($query) use ($ticket): void { + $query + ->where('source_ticket_id', $ticket->id) + ->orWhere('target_ticket_id', $ticket->id); + }) + ->orderBy('id') + ->get() + ->each(fn (TicketLink $ticketLink) => $ticketLink->setAttribute('current_ticket_id', $ticket->id)), + )->resolve(), + 'parentTicketOptions' => TicketReferenceResource::collection( + Ticket::query() + ->where('project_id', $project->id) + ->whereKeyNot($ticket->id) + ->whereHas('ticketType', fn ($query) => $query->where('is_parent', true)) + ->orderByDesc('sequence_number') + ->get(), + )->resolve(), + 'project' => ProjectResource::make($project)->resolve(), + 'repositoryBranches' => TicketRepositoryBranchResource::collection( + $this->resolveTicketBranchMatches->handle( + $user, + $project, + $ticket, + ), + )->resolve(), + 'repositoryPullRequests' => TicketRepositoryPullRequestResource::collection( + $this->resolveTicketPullRequestMatches->handle( + $user, + $project, + $ticket, + ), + )->resolve(), + 'status' => $status, + 'suggestedBranchName' => $this->buildSuggestedTicketBranchName->handle($ticket), + 'ticket' => TicketResource::make($displayTicket)->resolve(), + 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), + 'ticketLinkOptions' => TicketReferenceResource::collection( + Ticket::query() + ->where('project_id', $project->id) + ->whereKeyNot($ticket->id) + ->orderByDesc('sequence_number') + ->get(), + )->resolve(), + 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), + 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), + 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), + 'users' => UserResource::collection( + User::query() + ->orderBy('name') + ->get(), + )->resolve(), + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php b/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php new file mode 100644 index 0000000..1cc9c2c --- /dev/null +++ b/app/Actions/Projects/BuildProjectTicketMetadataSettingsPayload.php @@ -0,0 +1,32 @@ + + */ + public function handle(Project $project, mixed $status = null, mixed $error = null): array + { + return [ + 'fieldTypeOptions' => FieldTypeOptionResource::collection(collect(TicketFieldDefinition::FIELD_TYPES))->resolve(), + 'project' => ProjectResource::make($project)->resolve(), + 'error' => $error, + 'status' => $status, + 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), + 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), + 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), + 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(), + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php new file mode 100644 index 0000000..a22aea1 --- /dev/null +++ b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php @@ -0,0 +1,46 @@ + + */ + public function handle(Project $project, string $source = 'backlog', mixed $status = null): array + { + $source = $source === 'board' ? 'board' : 'backlog'; + $defaultTicketStatus = $source === 'board' + ? $project->ticketStatuses()->whereNotNull('board_column_id')->first() + : $project->ticketStatuses()->whereNull('board_column_id')->first(); + + $defaultTicketStatus ??= $project->ticketStatuses()->first(); + + return [ + 'defaultTicketStatusId' => $defaultTicketStatus?->id, + 'project' => ProjectResource::make($project)->resolve(), + 'source' => $source, + 'status' => $status, + 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), + 'ticketStatusOptions' => TicketStatusResource::collection( + $project->ticketStatuses() + ->with('boardColumn') + ->get(), + )->resolve(), + 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), + 'users' => UserResource::collection( + User::query() + ->orderBy('name') + ->get(), + )->resolve(), + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectTicketStatusTargetsPayload.php b/app/Actions/Projects/BuildProjectTicketStatusTargetsPayload.php new file mode 100644 index 0000000..463773f --- /dev/null +++ b/app/Actions/Projects/BuildProjectTicketStatusTargetsPayload.php @@ -0,0 +1,26 @@ + + */ + public function handle(Ticket $ticket): array + { + $ticket->loadMissing('ticketStatus.allowedTargetStatuses.boardColumn'); + + return [ + 'current_status' => $ticket->ticketStatus === null + ? null + : TicketStatusResource::make($ticket->ticketStatus->loadMissing('boardColumn'))->resolve(), + 'target_statuses' => TicketStatusResource::collection( + $ticket->ticketStatus?->allowedTargetStatuses ?? collect(), + )->resolve(), + ]; + } +} diff --git a/app/Actions/Projects/CreateProject.php b/app/Actions/Projects/CreateProject.php new file mode 100644 index 0000000..4baf52a --- /dev/null +++ b/app/Actions/Projects/CreateProject.php @@ -0,0 +1,30 @@ +create($attributes); + + $this->initializeProjectTicketMetadata->handle($project); + $this->permissionService->grant($actor, Permission::ProjectAdmin, $project); + + return $project; + } +} diff --git a/app/Actions/Projects/DeleteProject.php b/app/Actions/Projects/DeleteProject.php new file mode 100644 index 0000000..02a111d --- /dev/null +++ b/app/Actions/Projects/DeleteProject.php @@ -0,0 +1,17 @@ +name; + + $project->delete(); + + return $projectName; + } +} diff --git a/app/Actions/Projects/ListVisibleProjects.php b/app/Actions/Projects/ListVisibleProjects.php new file mode 100644 index 0000000..c6a7700 --- /dev/null +++ b/app/Actions/Projects/ListVisibleProjects.php @@ -0,0 +1,39 @@ + + */ + public function handle(User $user): Collection + { + $projectQuery = Project::query() + ->orderByDesc('id'); + + if ($this->permissionService->allows($user, Permission::GlobalAdmin)) { + return $projectQuery->get(); + } + + $viewableProjectIds = $this->permissionService->viewableProjectIds($user); + + if ($viewableProjectIds === []) { + return new Collection; + } + + return $projectQuery + ->whereIn('id', $viewableProjectIds) + ->get(); + } +} diff --git a/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php b/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php new file mode 100644 index 0000000..e49b10f --- /dev/null +++ b/app/Actions/Repositories/BuildProjectRepositorySettingsPayload.php @@ -0,0 +1,120 @@ + + */ + public function handle(Project $project, User $user, mixed $status = null): array + { + $repositoryConnections = $project->repositoryConnections() + ->get() + ->each(function (RepositoryConnection $repositoryConnection) use ($user): void { + $repositoryConnection->setAttribute( + 'has_personal_access', + $this->hasPersonalAccess($user, $repositoryConnection), + ); + }); + + return [ + 'availableRepositorySources' => AvailableGitRepositorySourceResource::collection( + $this->availableRepositorySources($user), + )->resolve(), + 'project' => ProjectResource::make($project)->resolve(), + 'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(), + 'status' => $status, + ]; + } + + /** + * @return array, + * user_git_connection_id: int + * }> + */ + public function availableRepositorySources(User $user): array + { + /** @var Collection $gitConnections */ + $gitConnections = $user->gitConnections() + ->get(); + + return $gitConnections + ->map(function (UserGitConnection $gitConnection): array { + try { + $repositories = collect( + $this->gitProviderManager + ->client($gitConnection->provider) + ->listRepositories($gitConnection->base_url, $gitConnection->api_token), + ) + ->map(fn (GitRepository $repository): array => [ + 'full_name' => $repository->fullName, + 'owner_name' => $repository->owner, + 'provider_repository_id' => $repository->providerRepositoryId, + 'repository_name' => $repository->name, + 'repository_url' => $repository->htmlUrl, + 'visibility_label' => $repository->isPrivate ? 'Private' : 'Public', + ]) + ->sortBy('full_name') + ->values() + ->all(); + + $error = null; + } catch (Throwable) { + $repositories = []; + $error = sprintf('%s could not load repositories from this Git connection right now.', config('app.name')); + } + + return [ + 'account_login' => $gitConnection->account_login, + 'account_name' => $gitConnection->account_name, + 'base_url' => $gitConnection->base_url, + 'error' => $error, + 'provider' => $gitConnection->provider->value, + 'provider_label' => $gitConnection->provider->label(), + 'repositories' => $repositories, + 'user_git_connection_id' => $gitConnection->id, + ]; + }) + ->values() + ->all(); + } + + public function hasPersonalAccess(User $user, RepositoryConnection $repositoryConnection): bool + { + return $user->gitConnections() + ->where('provider', $repositoryConnection->provider) + ->where('base_url', $repositoryConnection->base_url) + ->exists(); + } +} diff --git a/app/Actions/Repositories/DeleteProjectRepositoryConnection.php b/app/Actions/Repositories/DeleteProjectRepositoryConnection.php new file mode 100644 index 0000000..947dc80 --- /dev/null +++ b/app/Actions/Repositories/DeleteProjectRepositoryConnection.php @@ -0,0 +1,17 @@ +repository_full_name; + + $repositoryConnection->delete(); + + return $repositoryLabel; + } +} diff --git a/app/Actions/Repositories/ResolveRepositorySelectionAttributes.php b/app/Actions/Repositories/ResolveRepositorySelectionAttributes.php new file mode 100644 index 0000000..2980c97 --- /dev/null +++ b/app/Actions/Repositories/ResolveRepositorySelectionAttributes.php @@ -0,0 +1,70 @@ +ownedUserGitConnection($user, $userGitConnectionId); + $repository = $this->repositoryFromConnection($userGitConnection, $providerRepositoryId); + + if ($repository === null) { + throw ValidationException::withMessages([ + 'provider_repository_id' => 'The selected repository is not available through this Git connection.', + ]); + } + + return [ + 'base_url' => $userGitConnection->base_url, + 'owner_name' => $repository->owner, + 'provider' => $userGitConnection->provider, + 'provider_repository_id' => $repository->providerRepositoryId, + 'repository_full_name' => $repository->fullName, + 'repository_name' => $repository->name, + 'repository_url' => $repository->htmlUrl, + ]; + } + + private function ownedUserGitConnection(User $user, int $userGitConnectionId): UserGitConnection + { + $userGitConnection = UserGitConnection::query()->findOrFail($userGitConnectionId); + + abort_unless($userGitConnection->user_id === $user->id, 403); + + return $userGitConnection; + } + + private function repositoryFromConnection( + UserGitConnection $userGitConnection, + string $providerRepositoryId, + ): ?GitRepository { + return collect( + $this->gitProviderManager + ->client($userGitConnection->provider) + ->listRepositories($userGitConnection->base_url, $userGitConnection->api_token), + ) + ->first(fn (GitRepository $repository): bool => $repository->providerRepositoryId === $providerRepositoryId); + } +} diff --git a/app/Actions/Repositories/StoreProjectRepositoryConnection.php b/app/Actions/Repositories/StoreProjectRepositoryConnection.php new file mode 100644 index 0000000..db49b95 --- /dev/null +++ b/app/Actions/Repositories/StoreProjectRepositoryConnection.php @@ -0,0 +1,59 @@ +resolveRepositorySelectionAttributes->handle( + $user, + (int) Arr::get($validated, 'user_git_connection_id'), + (string) Arr::get($validated, 'provider_repository_id'), + ); + + $this->ensureRepositoryConnectionIsUnique($project, $repositoryAttributes); + + return $project->repositoryConnections()->create($repositoryAttributes); + } + + /** + * @param array{ + * base_url: string, + * owner_name: string, + * provider: mixed, + * provider_repository_id: string, + * repository_full_name: string, + * repository_name: string, + * repository_url: string + * } $repositoryAttributes + */ + private function ensureRepositoryConnectionIsUnique( + Project $project, + array $repositoryAttributes, + ): void { + $query = $project->repositoryConnections() + ->where('provider', $repositoryAttributes['provider']) + ->where('base_url', $repositoryAttributes['base_url']) + ->where('provider_repository_id', $repositoryAttributes['provider_repository_id']); + + if ($query->exists()) { + throw ValidationException::withMessages([ + 'provider_repository_id' => 'This repository is already linked to the project.', + ]); + } + } +} diff --git a/app/Actions/Repositories/UpdateProjectRepositoryConnection.php b/app/Actions/Repositories/UpdateProjectRepositoryConnection.php new file mode 100644 index 0000000..cbdd191 --- /dev/null +++ b/app/Actions/Repositories/UpdateProjectRepositoryConnection.php @@ -0,0 +1,67 @@ +resolveRepositorySelectionAttributes->handle( + $user, + (int) Arr::get($validated, 'user_git_connection_id'), + (string) Arr::get($validated, 'provider_repository_id'), + ); + + $this->ensureRepositoryConnectionIsUnique($project, $repositoryAttributes, $repositoryConnection); + + $repositoryConnection->update($repositoryAttributes); + + return $repositoryConnection->fresh(); + } + + /** + * @param array{ + * base_url: string, + * owner_name: string, + * provider: mixed, + * provider_repository_id: string, + * repository_full_name: string, + * repository_name: string, + * repository_url: string + * } $repositoryAttributes + */ + private function ensureRepositoryConnectionIsUnique( + Project $project, + array $repositoryAttributes, + RepositoryConnection $repositoryConnection, + ): void { + $query = $project->repositoryConnections() + ->where('provider', $repositoryAttributes['provider']) + ->where('base_url', $repositoryAttributes['base_url']) + ->where('provider_repository_id', $repositoryAttributes['provider_repository_id']) + ->whereKeyNot($repositoryConnection->id); + + if ($query->exists()) { + throw ValidationException::withMessages([ + 'provider_repository_id' => 'This repository is already linked to the project.', + ]); + } + } +} diff --git a/app/Actions/Tickets/CreateProjectTicketBranch.php b/app/Actions/Tickets/CreateProjectTicketBranch.php new file mode 100644 index 0000000..91a5b60 --- /dev/null +++ b/app/Actions/Tickets/CreateProjectTicketBranch.php @@ -0,0 +1,104 @@ +projectRepositoryConnection($project, $repositoryConnectionId); + $userGitConnection = $this->matchingUserGitConnection($user->id, $repositoryConnection); + + if ($userGitConnection === null) { + throw ValidationException::withMessages([ + 'repository_connection_id' => 'Connect a matching Git connection in your user settings first.', + ]); + } + + $syntaxMessage = $this->validateGitBranchName->message($branchName); + + if ($syntaxMessage !== null) { + throw ValidationException::withMessages([ + 'branch_name' => $syntaxMessage, + ]); + } + + $validation = $this->validateProjectTicketBranch->handle($project, $ticket, $user, $validated); + + if (! $validation['is_valid']) { + throw ValidationException::withMessages([ + 'branch_name' => $validation['message'], + ]); + } + + $gitProvider = $this->gitProviderManager->client($userGitConnection->provider); + + try { + $repository = $gitProvider->getRepository( + $userGitConnection->base_url, + $userGitConnection->api_token, + $repositoryConnection->owner_name, + $repositoryConnection->repository_name, + ); + + $gitProvider->createBranch( + $userGitConnection->base_url, + $userGitConnection->api_token, + $repositoryConnection->owner_name, + $repositoryConnection->repository_name, + $branchName, + $repository->defaultBranch, + ); + } catch (Throwable) { + throw ValidationException::withMessages([ + 'branch_name' => sprintf('%s could not create this branch right now.', config('app.name')), + ]); + } + + return [ + 'branch_name' => $branchName, + 'repository_full_name' => $repositoryConnection->repository_full_name, + ]; + } + + private function projectRepositoryConnection(Project $project, int $repositoryConnectionId): RepositoryConnection + { + return $project->repositoryConnections() + ->findOrFail($repositoryConnectionId); + } + + private function matchingUserGitConnection( + int $userId, + RepositoryConnection $repositoryConnection, + ): ?UserGitConnection { + return UserGitConnection::query() + ->where('user_id', $userId) + ->where('provider', $repositoryConnection->provider) + ->where('base_url', $repositoryConnection->base_url) + ->first(); + } +} diff --git a/app/Actions/Tickets/DeleteProjectTicketLink.php b/app/Actions/Tickets/DeleteProjectTicketLink.php new file mode 100644 index 0000000..12837f5 --- /dev/null +++ b/app/Actions/Tickets/DeleteProjectTicketLink.php @@ -0,0 +1,60 @@ +load(['sourceTicket', 'targetTicket', 'ticketLinkType']); + + abort_unless( + $ticketLink->source_ticket_id === $ticket->id || $ticketLink->target_ticket_id === $ticket->id, + 404, + ); + + $linkedTicket = $ticketLink->source_ticket_id === $ticket->id + ? $ticketLink->targetTicket + : $ticketLink->sourceTicket; + + $label = $ticketLink->ticketLinkType->label; + + DB::transaction(function () use ($label, $linkedTicket, $user, $ticket, $ticketLink): void { + $ticketLink->delete(); + + $this->createTicketWorklogEntry->handle( + $ticket, + $user, + TicketWorklogEntryType::LinkRemoved, + "Removed {$label} link to {$linkedTicket->ticket_key}.", + [ + 'linked_ticket_key' => $linkedTicket->ticket_key, + 'ticket_link_type' => $label, + ], + ); + + $this->createTicketWorklogEntry->handle( + $linkedTicket, + $user, + TicketWorklogEntryType::LinkRemoved, + "Removed {$label} link to {$ticket->ticket_key}.", + [ + 'linked_ticket_key' => $ticket->ticket_key, + 'ticket_link_type' => $label, + ], + ); + }); + + return $label; + } +} diff --git a/app/Actions/Tickets/MoveProjectTicket.php b/app/Actions/Tickets/MoveProjectTicket.php new file mode 100644 index 0000000..79b74ae --- /dev/null +++ b/app/Actions/Tickets/MoveProjectTicket.php @@ -0,0 +1,86 @@ +ticketStatuses() + ->with('boardColumn') + ->findOrFail($ticketStatusId); + + $ticket->loadMissing('ticketStatus.boardColumn'); + + $originStatusId = $ticket->ticket_status_id; + $originLabel = $this->statusLabel($ticket->ticketStatus); + $destinationLabel = $this->statusLabel($destinationStatus); + + if ($ticket->ticket_status_id === $ticketStatusId) { + return [ + 'destination_label' => $destinationLabel, + 'moved' => false, + ]; + } + + $ticket->update([ + 'ticket_status_id' => $destinationStatus->id, + ]); + + $this->createTicketWorklogEntry->handle( + $ticket->fresh(), + $user, + TicketWorklogEntryType::StatusChanged, + "Moved from {$originLabel} to {$destinationLabel}.", + [ + 'from' => [ + 'label' => $originLabel, + 'ticket_status_id' => $originStatusId, + ], + 'to' => [ + 'label' => $destinationLabel, + 'ticket_status_id' => $destinationStatus->id, + ], + ], + ); + + return [ + 'destination_label' => $destinationLabel, + 'moved' => true, + ]; + } + + private function statusLabel(?TicketStatus $ticketStatus): string + { + if ($ticketStatus === null) { + return 'Unknown status'; + } + + if ($ticketStatus->boardColumn === null) { + return $ticketStatus->name; + } + + if ($ticketStatus->boardColumn->name === $ticketStatus->name) { + return $ticketStatus->name; + } + + return "{$ticketStatus->boardColumn->name} / {$ticketStatus->name}"; + } +} diff --git a/app/Actions/Tickets/StoreProjectTicketLink.php b/app/Actions/Tickets/StoreProjectTicketLink.php new file mode 100644 index 0000000..5130dbb --- /dev/null +++ b/app/Actions/Tickets/StoreProjectTicketLink.php @@ -0,0 +1,82 @@ +where('project_id', $project->id) + ->findOrFail($linkedTicketId); + + [$sourceTicketId, $targetTicketId] = $this->normalizeTicketPair($ticket->id, $linkedTicket->id); + + return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $sourceTicketId, $targetTicketId, $ticket): TicketLink { + $ticketLink = TicketLink::query()->updateOrCreate( + [ + 'source_ticket_id' => $sourceTicketId, + 'target_ticket_id' => $targetTicketId, + ], + [ + 'ticket_link_type_id' => $ticketLinkTypeId, + ], + ); + + $ticketLink->load('ticketLinkType'); + + $this->createTicketWorklogEntry->handle( + $ticket, + $user, + TicketWorklogEntryType::LinkUpdated, + "Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.", + [ + 'linked_ticket_key' => $linkedTicket->ticket_key, + 'ticket_link_type' => $ticketLink->ticketLinkType->label, + ], + ); + + $this->createTicketWorklogEntry->handle( + $linkedTicket, + $user, + TicketWorklogEntryType::LinkUpdated, + "Linked {$ticket->ticket_key} as {$ticketLink->ticketLinkType->label}.", + [ + 'linked_ticket_key' => $ticket->ticket_key, + 'ticket_link_type' => $ticketLink->ticketLinkType->label, + ], + ); + + return $ticketLink; + }); + } + + /** + * @return array{0: int, 1: int} + */ + private function normalizeTicketPair(int $firstTicketId, int $secondTicketId): array + { + return [ + min($firstTicketId, $secondTicketId), + max($firstTicketId, $secondTicketId), + ]; + } +} diff --git a/app/Actions/Tickets/ValidateProjectTicketBranch.php b/app/Actions/Tickets/ValidateProjectTicketBranch.php new file mode 100644 index 0000000..5abfa8a --- /dev/null +++ b/app/Actions/Tickets/ValidateProjectTicketBranch.php @@ -0,0 +1,99 @@ +projectRepositoryConnection($project, $repositoryConnectionId); + $userGitConnection = $this->matchingUserGitConnection($user->id, $repositoryConnection); + + if ($userGitConnection === null) { + return [ + 'is_valid' => false, + 'message' => 'Connect a matching Git connection in your user settings first.', + ]; + } + + $syntaxMessage = $this->validateGitBranchName->message($branchName); + + if ($syntaxMessage !== null) { + return [ + 'is_valid' => false, + 'message' => $syntaxMessage, + ]; + } + + try { + $branchAlreadyExists = collect( + $this->gitProviderManager + ->client($userGitConnection->provider) + ->listBranches( + $userGitConnection->api_token, + $repositoryConnection->toGitRepository(), + ), + ) + ->contains( + fn (GitBranch $branch): bool => $branch->name === $branchName, + ); + } catch (Throwable) { + return [ + 'is_valid' => false, + 'message' => sprintf('%s could not validate this branch name right now.', config('app.name')), + ]; + } + + if ($branchAlreadyExists) { + return [ + 'is_valid' => false, + 'message' => 'This branch name already exists in the selected repository.', + ]; + } + + return [ + 'is_valid' => true, + 'message' => "Branch name is available for {$ticket->ticket_key}.", + ]; + } + + private function projectRepositoryConnection(Project $project, int $repositoryConnectionId): RepositoryConnection + { + return $project->repositoryConnections() + ->findOrFail($repositoryConnectionId); + } + + private function matchingUserGitConnection( + int $userId, + RepositoryConnection $repositoryConnection, + ): ?UserGitConnection { + return UserGitConnection::query() + ->where('user_id', $userId) + ->where('provider', $repositoryConnection->provider) + ->where('base_url', $repositoryConnection->base_url) + ->first(); + } +} diff --git a/app/Actions/UserGitConnections/DeleteUserGitConnection.php b/app/Actions/UserGitConnections/DeleteUserGitConnection.php new file mode 100644 index 0000000..0df51e4 --- /dev/null +++ b/app/Actions/UserGitConnections/DeleteUserGitConnection.php @@ -0,0 +1,17 @@ +account_login} @ {$gitConnection->base_url}"; + + $gitConnection->delete(); + + return $label; + } +} diff --git a/app/Actions/UserGitConnections/StoreUserGitConnection.php b/app/Actions/UserGitConnections/StoreUserGitConnection.php new file mode 100644 index 0000000..d801fc9 --- /dev/null +++ b/app/Actions/UserGitConnections/StoreUserGitConnection.php @@ -0,0 +1,50 @@ +gitProviderManager->client($provider); + $authenticatedUser = $gitProvider->getAuthenticatedUser( + $baseUrl, + $apiToken, + ); + } catch (Throwable) { + throw ValidationException::withMessages([ + 'api_token' => sprintf('%s could not authenticate against Gitea with this base URL and token.', config('app.name')), + ]); + } + + return UserGitConnection::query()->create([ + 'account_login' => $authenticatedUser->login, + 'account_name' => $authenticatedUser->name, + 'api_token' => $apiToken, + 'base_url' => $baseUrl, + 'provider' => $provider, + 'provider_user_id' => $authenticatedUser->providerUserId, + 'user_id' => $user->id, + ]); + } +} diff --git a/app/Actions/UserGitConnections/UpdateUserGitConnection.php b/app/Actions/UserGitConnections/UpdateUserGitConnection.php new file mode 100644 index 0000000..073f4e8 --- /dev/null +++ b/app/Actions/UserGitConnections/UpdateUserGitConnection.php @@ -0,0 +1,50 @@ +gitProviderManager->client($provider); + $authenticatedUser = $gitProvider->getAuthenticatedUser( + $baseUrl, + $apiToken, + ); + } catch (Throwable) { + throw ValidationException::withMessages([ + 'api_token' => sprintf('%s could not authenticate against Gitea with this base URL and token.', config('app.name')), + ]); + } + + $gitConnection->update([ + 'account_login' => $authenticatedUser->login, + 'account_name' => $authenticatedUser->name, + 'api_token' => $apiToken, + 'base_url' => $baseUrl, + 'provider' => $provider, + 'provider_user_id' => $authenticatedUser->providerUserId, + ]); + + return $gitConnection->fresh(); + } +} diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php new file mode 100644 index 0000000..2e68a16 --- /dev/null +++ b/app/Http/Controllers/Api/ApiController.php @@ -0,0 +1,19 @@ +user(); + + abort_if($user === null, 401); + + return $user; + } +} diff --git a/app/Http/Controllers/Api/ProjectBoardController.php b/app/Http/Controllers/Api/ProjectBoardController.php new file mode 100644 index 0000000..f1c9f73 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectBoardController.php @@ -0,0 +1,48 @@ +authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectRead, $project), + 403, + ); + + return response()->json([ + 'data' => $this->buildProjectBoardPayload->handle($project, $user), + ]); + } + + public function backlog(Request $request, Project $project): JsonResponse + { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectRead, $project), + 403, + ); + + return response()->json([ + 'data' => $this->buildProjectBacklogPayload->handle($project, $user), + ]); + } +} diff --git a/app/Http/Controllers/Api/ProjectBoardSettingsController.php b/app/Http/Controllers/Api/ProjectBoardSettingsController.php new file mode 100644 index 0000000..b3227a1 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectBoardSettingsController.php @@ -0,0 +1,167 @@ +abortUnlessCanManage($request, $project); + + return response()->json([ + 'data' => $this->buildProjectBoardSettingsPayload->handle($project), + ]); + } + + public function storeColumn(StoreBoardColumnRequest $request, Project $project): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $boardColumn = $this->storeBoardColumn->handle($project, $request->validated()); + + return BoardColumnResource::make($boardColumn->load('ticketStatuses')) + ->additional([ + 'meta' => [ + 'message' => "{$boardColumn->name} was added to the board workflow.", + ], + ]) + ->response() + ->setStatusCode(201); + } + + public function updateColumn( + UpdateBoardColumnRequest $request, + Project $project, + BoardColumn $boardColumn, + ): JsonResponse { + $this->abortUnlessCanManage($request, $project); + + $boardColumn = $this->updateBoardColumn->handle($project, $boardColumn, $request->validated()); + + return BoardColumnResource::make($boardColumn->load('ticketStatuses')) + ->additional([ + 'meta' => [ + 'message' => "{$boardColumn->name} was updated.", + ], + ]) + ->response(); + } + + public function destroyColumn(Request $request, Project $project, BoardColumn $boardColumn): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $this->deleteBoardColumn->handle($project, $boardColumn); + + return response()->json(null, 204); + } + + public function storeStatus(StoreTicketStatusRequest $request, Project $project): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $ticketStatus = $this->storeTicketStatus->handle($project, $request->validated()); + + return TicketStatusResource::make($ticketStatus) + ->additional([ + 'meta' => [ + 'message' => "{$ticketStatus->name} was added to {$this->statusContextLabel($ticketStatus)}.", + ], + ]) + ->response() + ->setStatusCode(201); + } + + public function updateStatus( + UpdateTicketStatusRequest $request, + Project $project, + TicketStatus $ticketStatus, + ): JsonResponse { + $this->abortUnlessCanManage($request, $project); + + $ticketStatus = $this->updateTicketStatus->handle($ticketStatus, $request->validated()); + + return TicketStatusResource::make($ticketStatus->load('boardColumn')) + ->additional([ + 'meta' => [ + 'message' => "{$ticketStatus->name} was updated.", + ], + ]) + ->response(); + } + + public function destroyStatus(Request $request, Project $project, TicketStatus $ticketStatus): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $this->deleteTicketStatus->handle($project, $ticketStatus); + + return response()->json(null, 204); + } + + public function updateStatusTransitions( + UpdateTicketStatusTransitionsRequest $request, + Project $project, + TicketStatus $ticketStatus, + ): JsonResponse { + $this->abortUnlessCanManage($request, $project); + + $ticketStatus = $this->updateTicketStatusTransitions->handle($ticketStatus, $request->validated()); + + return TicketStatusResource::make($ticketStatus->load(['allowedTargetStatuses.boardColumn', 'boardColumn'])) + ->additional([ + 'meta' => [ + 'message' => "{$ticketStatus->name} move targets were updated.", + ], + ]) + ->response(); + } + + private function abortUnlessCanManage(Request $request, Project $project): void + { + abort_unless( + $this->permissionService->allows($this->authenticatedUser($request), Permission::ProjectAdmin, $project), + 403, + ); + } + + private function statusContextLabel(TicketStatus $ticketStatus): string + { + return $ticketStatus->boardColumn?->name ?? 'Backlog'; + } +} diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php new file mode 100644 index 0000000..3fcbd19 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectController.php @@ -0,0 +1,68 @@ +listVisibleProjects->handle($this->authenticatedUser($request)), + ); + } + + public function store(StoreProjectRequest $request): JsonResponse + { + $project = $this->createProject->handle($request->validated(), $this->authenticatedUser($request)); + + return ProjectResource::make($project) + ->additional([ + 'meta' => [ + 'message' => "{$project->name} was created.", + ], + ]) + ->response() + ->setStatusCode(201); + } + + public function show(Request $request, Project $project): ProjectResource + { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectRead, $project), + 403, + ); + + return ProjectResource::make($project); + } + + public function destroy(DeleteProjectRequest $request, Project $project): JsonResponse + { + $request->validated(); + + $this->deleteProject->handle($project); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/ProjectRepositorySettingsController.php b/app/Http/Controllers/Api/ProjectRepositorySettingsController.php new file mode 100644 index 0000000..0c02400 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectRepositorySettingsController.php @@ -0,0 +1,105 @@ +authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), + 403, + ); + + return response()->json([ + 'data' => $this->buildProjectRepositorySettingsPayload->handle($project, $user), + ]); + } + + public function store(StoreRepositoryConnectionRequest $request, Project $project): JsonResponse + { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), + 403, + ); + + $repositoryConnection = $this->storeProjectRepositoryConnection->handle($project, $user, $request->validated()); + + return RepositoryConnectionResource::make($repositoryConnection) + ->additional([ + 'meta' => [ + 'message' => "{$repositoryConnection->repository_full_name} was linked to {$project->name}.", + ], + ]) + ->response() + ->setStatusCode(201); + } + + public function update( + UpdateRepositoryConnectionRequest $request, + Project $project, + RepositoryConnection $repositoryConnection, + ): JsonResponse { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), + 403, + ); + + $repositoryConnection = $this->updateProjectRepositoryConnection->handle( + $project, + $repositoryConnection, + $user, + $request->validated(), + ); + + return RepositoryConnectionResource::make($repositoryConnection) + ->additional([ + 'meta' => [ + 'message' => "{$repositoryConnection->repository_full_name} connection was updated.", + ], + ]) + ->response(); + } + + public function destroy(Request $request, Project $project, RepositoryConnection $repositoryConnection): JsonResponse + { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectAdmin, $project), + 403, + ); + + $this->deleteProjectRepositoryConnection->handle($repositoryConnection); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/ProjectTicketBranchController.php b/app/Http/Controllers/Api/ProjectTicketBranchController.php new file mode 100644 index 0000000..4be97c2 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectTicketBranchController.php @@ -0,0 +1,61 @@ +authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + return response()->json([ + 'data' => $this->validateProjectTicketBranch->handle($project, $ticket, $user, $request->validated()), + ]); + } + + public function store( + StoreProjectTicketBranchRequest $request, + Project $project, + Ticket $ticket, + ): JsonResponse { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + $result = $this->createProjectTicketBranch->handle($project, $ticket, $user, $request->validated()); + + return response()->json([ + 'data' => $result, + 'meta' => [ + 'message' => "{$result['branch_name']} was created in {$result['repository_full_name']}.", + ], + ], 201); + } +} diff --git a/app/Http/Controllers/Api/ProjectTicketController.php b/app/Http/Controllers/Api/ProjectTicketController.php new file mode 100644 index 0000000..02409e5 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectTicketController.php @@ -0,0 +1,183 @@ +authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectRead, $project), + 403, + ); + + return TicketResource::collection( + $project->tickets() + ->with([ + 'ticketPriority', + 'ticketStatus.allowedTargetStatuses.boardColumn', + 'ticketStatus.boardColumn', + 'ticketType', + ]) + ->get(), + ); + } + + public function options(Request $request, Project $project): JsonResponse + { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + return response()->json([ + 'data' => $this->buildProjectTicketOptionsPayload->handle( + $project, + Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog', + ), + ]); + } + + public function store(StoreProjectTicketRequest $request, Project $project): JsonResponse + { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + $ticket = $this->createProjectTicket->handle($project, $request->validated(), $user); + + return TicketResource::make($ticket->load(['ticketPriority', 'ticketStatus.boardColumn', 'ticketType'])) + ->additional([ + 'meta' => [ + 'message' => "{$ticket->ticket_key} was created.", + ], + ]) + ->response() + ->setStatusCode(201); + } + + public function show(Request $request, Project $project, Ticket $ticket): JsonResponse + { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectRead, $project), + 403, + ); + + return response()->json([ + 'data' => $this->buildProjectTicketDetailPayload->handle($project, $ticket, $user), + ]); + } + + public function update( + UpdateProjectTicketRequest $request, + Project $project, + Ticket $ticket, + ): JsonResponse { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $request->validated()); + $statusMessage = $wasUpdated + ? "{$ticket->ticket_key} was updated." + : 'No ticket changes were detected.'; + + return TicketResource::make($ticket->fresh()->load([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + 'worklogEntries.actor', + ])) + ->additional([ + 'meta' => [ + 'message' => $statusMessage, + ], + ]) + ->response(); + } + + public function statusTargets(Request $request, Project $project, Ticket $ticket): JsonResponse + { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectRead, $project), + 403, + ); + + return response()->json([ + 'data' => $this->buildProjectTicketStatusTargetsPayload->handle($ticket), + ]); + } + + public function move( + MoveProjectTicketRequest $request, + Project $project, + Ticket $ticket, + ): JsonResponse { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + $result = $this->moveProjectTicket->handle($project, $ticket, $user, $request->validated()); + $message = $result['moved'] + ? "{$ticket->ticket_key} moved to {$result['destination_label']}." + : "{$ticket->ticket_key} already belongs to {$result['destination_label']}."; + + return TicketResource::make($ticket->fresh()->load(['ticketPriority', 'ticketStatus.boardColumn', 'ticketType'])) + ->additional([ + 'meta' => [ + 'message' => $message, + ], + ]) + ->response(); + } +} diff --git a/app/Http/Controllers/Api/ProjectTicketLinkController.php b/app/Http/Controllers/Api/ProjectTicketLinkController.php new file mode 100644 index 0000000..6555afd --- /dev/null +++ b/app/Http/Controllers/Api/ProjectTicketLinkController.php @@ -0,0 +1,67 @@ +authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + $ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated()); + $ticketLink->setAttribute('current_ticket_id', $ticket->id); + + return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType'])) + ->additional([ + 'meta' => [ + 'message' => "{$ticketLink->ticketLinkType->label} link was saved.", + ], + ]) + ->response() + ->setStatusCode(201); + } + + public function destroy( + Request $request, + Project $project, + Ticket $ticket, + TicketLink $ticketLink, + ): JsonResponse { + $user = $this->authenticatedUser($request); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + $this->deleteProjectTicketLink->handle($ticket, $ticketLink, $user); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/Api/ProjectTicketMetadataSettingsController.php new file mode 100644 index 0000000..174d939 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectTicketMetadataSettingsController.php @@ -0,0 +1,254 @@ +abortUnlessCanManage($request, $project); + + return response()->json([ + 'data' => $this->buildProjectTicketMetadataSettingsPayload->handle($project), + ]); + } + + public function update(UpdateProjectTicketMetadataSettingsRequest $request, Project $project): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $project = $this->updateProjectTicketMetadataSettings->handle($project, $request->validated()); + + return ProjectResource::make($project) + ->additional([ + 'meta' => [ + 'message' => 'Settings have been updated.', + ], + ]) + ->response(); + } + + public function storeType(StoreProjectTicketTypeRequest $request, Project $project): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $ticketType = $this->storeProjectTicketType->handle($project, $request->validated()); + + return TicketTypeResource::make($ticketType) + ->additional(['meta' => ['message' => "{$ticketType->name} type was added."]]) + ->response() + ->setStatusCode(201); + } + + public function updateType( + UpdateProjectTicketTypeRequest $request, + Project $project, + TicketType $ticketType, + ): JsonResponse { + $this->abortUnlessCanManage($request, $project); + + $ticketType = $this->updateProjectTicketType->handle($project, $ticketType, $request->validated()); + + return TicketTypeResource::make($ticketType) + ->additional(['meta' => ['message' => "{$ticketType->name} type was updated."]]) + ->response(); + } + + public function destroyType( + DestroyProjectTicketTypeRequest $request, + Project $project, + TicketType $ticketType, + ): JsonResponse { + $this->abortUnlessCanManage($request, $project); + + $result = $this->deleteProjectTicketType->handle( + $project, + $ticketType, + $this->authenticatedUser($request), + $request->validated(), + ); + + if (! $result['deleted']) { + throw ValidationException::withMessages([ + 'ticket_type_id' => $result['message'], + ]); + } + + return response()->json(null, 204); + } + + public function storePriority(StoreProjectNamedSettingRequest $request, Project $project): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $ticketPriority = $this->storeProjectTicketPriority->handle($project, $request->validated()); + + return TicketPriorityResource::make($ticketPriority) + ->additional(['meta' => ['message' => "{$ticketPriority->name} priority was added."]]) + ->response() + ->setStatusCode(201); + } + + public function updatePriority( + UpdateProjectNamedSettingRequest $request, + Project $project, + TicketPriority $ticketPriority, + ): JsonResponse { + $this->abortUnlessCanManage($request, $project); + + $ticketPriority = $this->updateProjectTicketPriority->handle($project, $ticketPriority, $request->validated()); + + return TicketPriorityResource::make($ticketPriority) + ->additional(['meta' => ['message' => "{$ticketPriority->name} priority was updated."]]) + ->response(); + } + + public function destroyPriority(Request $request, Project $project, TicketPriority $ticketPriority): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $result = $this->deleteProjectTicketPriority->handle($project, $ticketPriority); + + if (! $result['deleted']) { + throw ValidationException::withMessages([ + 'ticket_priority_id' => $result['message'], + ]); + } + + return response()->json(null, 204); + } + + public function storeFieldDefinition(StoreTicketFieldDefinitionRequest $request, Project $project): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $ticketFieldDefinition = $this->storeTicketFieldDefinition->handle($project, $request->validated()); + + return TicketFieldDefinitionResource::make($ticketFieldDefinition) + ->additional(['meta' => ['message' => "{$ticketFieldDefinition->label} field was added."]]) + ->response() + ->setStatusCode(201); + } + + public function updateFieldDefinition( + UpdateTicketFieldDefinitionRequest $request, + Project $project, + TicketFieldDefinition $ticketFieldDefinition, + ): JsonResponse { + $this->abortUnlessCanManage($request, $project); + + $ticketFieldDefinition = $this->updateTicketFieldDefinition->handle($project, $ticketFieldDefinition, $request->validated()); + + return TicketFieldDefinitionResource::make($ticketFieldDefinition) + ->additional(['meta' => ['message' => "{$ticketFieldDefinition->label} field was updated."]]) + ->response(); + } + + public function destroyFieldDefinition(Request $request, Project $project, TicketFieldDefinition $ticketFieldDefinition): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $this->deleteTicketFieldDefinition->handle($project, $ticketFieldDefinition); + + return response()->json(null, 204); + } + + public function storeLinkType(StoreProjectNamedSettingRequest $request, Project $project): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $ticketLinkType = $this->storeTicketLinkType->handle($project, $request->validated()); + + return TicketLinkTypeResource::make($ticketLinkType) + ->additional(['meta' => ['message' => "{$ticketLinkType->label} link type was added."]]) + ->response() + ->setStatusCode(201); + } + + public function updateLinkType( + UpdateProjectNamedSettingRequest $request, + Project $project, + TicketLinkType $ticketLinkType, + ): JsonResponse { + $this->abortUnlessCanManage($request, $project); + + $ticketLinkType = $this->updateTicketLinkType->handle($project, $ticketLinkType, $request->validated()); + + return TicketLinkTypeResource::make($ticketLinkType) + ->additional(['meta' => ['message' => "{$ticketLinkType->label} link type was updated."]]) + ->response(); + } + + public function destroyLinkType(Request $request, Project $project, TicketLinkType $ticketLinkType): JsonResponse + { + $this->abortUnlessCanManage($request, $project); + + $this->deleteTicketLinkType->handle($project, $ticketLinkType); + + return response()->json(null, 204); + } + + private function abortUnlessCanManage(Request $request, Project $project): void + { + abort_unless( + $this->permissionService->allows($this->authenticatedUser($request), Permission::ProjectAdmin, $project), + 403, + ); + } +} diff --git a/app/Http/Controllers/Api/Settings/GitConnectionController.php b/app/Http/Controllers/Api/Settings/GitConnectionController.php new file mode 100644 index 0000000..d1d7125 --- /dev/null +++ b/app/Http/Controllers/Api/Settings/GitConnectionController.php @@ -0,0 +1,68 @@ +authenticatedUser($request) + ->gitConnections() + ->get(), + ); + } + + public function store(StoreUserGitConnectionRequest $request): JsonResponse + { + $gitConnection = $this->storeUserGitConnection->handle($this->authenticatedUser($request), $request->validated()); + + return UserGitConnectionResource::make($gitConnection) + ->additional(['meta' => ['message' => 'Git connection was added.']]) + ->response() + ->setStatusCode(201); + } + + public function update(UpdateUserGitConnectionRequest $request, UserGitConnection $gitConnection): JsonResponse + { + $this->abortUnlessOwner($request, $gitConnection); + + $gitConnection = $this->updateUserGitConnection->handle($gitConnection, $request->validated()); + + return UserGitConnectionResource::make($gitConnection) + ->additional(['meta' => ['message' => 'Git connection was updated.']]) + ->response(); + } + + public function destroy(Request $request, UserGitConnection $gitConnection): JsonResponse + { + $this->abortUnlessOwner($request, $gitConnection); + + $this->deleteUserGitConnection->handle($gitConnection); + + return response()->json(null, 204); + } + + private function abortUnlessOwner(Request $request, UserGitConnection $gitConnection): void + { + abort_unless($gitConnection->user_id === $this->authenticatedUser($request)->id, 403); + } +} diff --git a/app/Http/Controllers/ProjectBoardController.php b/app/Http/Controllers/ProjectBoardController.php index 48297d1..511f68f 100644 --- a/app/Http/Controllers/ProjectBoardController.php +++ b/app/Http/Controllers/ProjectBoardController.php @@ -3,15 +3,12 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; -use App\Actions\Tickets\CreateTicketWorklogEntry; +use App\Actions\Projects\BuildProjectBoardPayload; +use App\Actions\Tickets\MoveProjectTicket; use App\Enums\Permission; -use App\Enums\TicketWorklogEntryType; use App\Http\Requests\MoveProjectTicketRequest; -use App\Http\Resources\BoardColumnResource; -use App\Http\Resources\ProjectResource; use App\Models\Project; use App\Models\Ticket; -use App\Models\TicketStatus; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -21,7 +18,8 @@ use Inertia\Response; class ProjectBoardController extends Controller { public function __construct( - private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, + private readonly BuildProjectBoardPayload $buildProjectBoardPayload, + private readonly MoveProjectTicket $moveProjectTicket, private readonly PermissionService $permissionService, ) {} @@ -34,28 +32,10 @@ class ProjectBoardController extends Controller 403, ); - $canManageTickets = $this->permissionService->allows($user, Permission::ProjectWrite, $project); - - return Inertia::render('projects/Board', [ - 'boardColumns' => BoardColumnResource::collection( - $project->boardColumns() - ->with([ - 'ticketStatuses', - 'tickets' => fn ($query) => $query - ->with([ - 'ticketPriority', - 'ticketStatus.allowedTargetStatuses.boardColumn', - 'ticketStatus.boardColumn', - 'ticketType', - ]), - ]) - ->get(), - )->resolve(), - 'canCreateTickets' => $canManageTickets, - 'canEditTickets' => $canManageTickets, - 'project' => ProjectResource::make($project)->resolve(), - 'status' => session('status'), - ]); + return Inertia::render( + 'projects/Board', + $this->buildProjectBoardPayload->handle($project, $user, session('status')), + ); } public function move( @@ -71,65 +51,18 @@ class ProjectBoardController extends Controller ); $validated = $request->validated(); - $ticketStatusId = (int) Arr::get($validated, 'ticket_status_id'); $redirectRoute = Arr::get($validated, 'redirect_to') === 'backlog' ? 'projects.backlog.index' : 'projects.board.index'; - $destinationStatus = $project->ticketStatuses() - ->with('boardColumn') - ->findOrFail($ticketStatusId); + $result = $this->moveProjectTicket->handle($project, $ticket, $user, $validated); - $ticket->loadMissing('ticketStatus.boardColumn'); - - $originStatusId = $ticket->ticket_status_id; - $originLabel = $this->statusLabel($ticket->ticketStatus); - $destinationLabel = $this->statusLabel($destinationStatus); - - if ($ticket->ticket_status_id === $ticketStatusId) { + if (! $result['moved']) { return to_route($redirectRoute, $project) - ->with('status', "{$ticket->ticket_key} already belongs to {$destinationLabel}."); + ->with('status', "{$ticket->ticket_key} already belongs to {$result['destination_label']}."); } - $ticket->update([ - 'ticket_status_id' => $destinationStatus->id, - ]); - - $this->createTicketWorklogEntry->handle( - $ticket->fresh(), - $user, - TicketWorklogEntryType::StatusChanged, - "Moved from {$originLabel} to {$destinationLabel}.", - [ - 'from' => [ - 'label' => $originLabel, - 'ticket_status_id' => $originStatusId, - ], - 'to' => [ - 'label' => $destinationLabel, - 'ticket_status_id' => $destinationStatus->id, - ], - ], - ); - return to_route($redirectRoute, $project) - ->with('status', "{$ticket->ticket_key} moved to {$destinationLabel}."); - } - - private function statusLabel(?TicketStatus $ticketStatus): string - { - if ($ticketStatus === null) { - return 'Unknown status'; - } - - if ($ticketStatus->boardColumn === null) { - return $ticketStatus->name; - } - - if ($ticketStatus->boardColumn->name === $ticketStatus->name) { - return $ticketStatus->name; - } - - return "{$ticketStatus->boardColumn->name} / {$ticketStatus->name}"; + ->with('status', "{$ticket->ticket_key} moved to {$result['destination_label']}."); } } diff --git a/app/Http/Controllers/ProjectBoardSettingsController.php b/app/Http/Controllers/ProjectBoardSettingsController.php index 30fcaff..bd65152 100644 --- a/app/Http/Controllers/ProjectBoardSettingsController.php +++ b/app/Http/Controllers/ProjectBoardSettingsController.php @@ -3,31 +3,40 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; +use App\Actions\ProjectBoard\DeleteBoardColumn; +use App\Actions\ProjectBoard\DeleteTicketStatus; +use App\Actions\ProjectBoard\StoreBoardColumn; +use App\Actions\ProjectBoard\StoreTicketStatus; +use App\Actions\ProjectBoard\UpdateBoardColumn; +use App\Actions\ProjectBoard\UpdateTicketStatus; +use App\Actions\ProjectBoard\UpdateTicketStatusTransitions; +use App\Actions\Projects\BuildProjectBoardSettingsPayload; use App\Enums\Permission; use App\Http\Requests\StoreBoardColumnRequest; use App\Http\Requests\StoreTicketStatusRequest; use App\Http\Requests\UpdateBoardColumnRequest; use App\Http\Requests\UpdateTicketStatusRequest; use App\Http\Requests\UpdateTicketStatusTransitionsRequest; -use App\Http\Resources\BoardColumnResource; -use App\Http\Resources\ProjectResource; -use App\Http\Resources\TicketStatusResource; use App\Models\BoardColumn; use App\Models\Project; use App\Models\TicketStatus; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; class ProjectBoardSettingsController extends Controller { public function __construct( + private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload, + private readonly DeleteBoardColumn $deleteBoardColumn, + private readonly DeleteTicketStatus $deleteTicketStatus, private readonly PermissionService $permissionService, + private readonly StoreBoardColumn $storeBoardColumn, + private readonly StoreTicketStatus $storeTicketStatus, + private readonly UpdateBoardColumn $updateBoardColumn, + private readonly UpdateTicketStatus $updateTicketStatus, + private readonly UpdateTicketStatusTransitions $updateTicketStatusTransitions, ) {} public function show(Request $request, Project $project): Response @@ -37,25 +46,10 @@ class ProjectBoardSettingsController extends Controller 403, ); - return Inertia::render('projects/settings/Board', [ - 'backlogTicketStatuses' => TicketStatusResource::collection( - $project->ticketStatuses() - ->whereNull('board_column_id') - ->get(), - )->resolve(), - 'boardColumns' => BoardColumnResource::collection( - $project->boardColumns() - ->with('ticketStatuses') - ->get(), - )->resolve(), - 'project' => ProjectResource::make($project)->resolve(), - 'status' => session('status'), - 'ticketStatuses' => TicketStatusResource::collection( - $project->ticketStatuses() - ->with(['allowedTargetStatuses.boardColumn', 'boardColumn']) - ->get(), - )->resolve(), - ]); + return Inertia::render( + 'projects/settings/Board', + $this->buildProjectBoardSettingsPayload->handle($project, session('status')), + ); } public function store( @@ -66,25 +60,7 @@ class ProjectBoardSettingsController extends Controller $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); - $validated = $request->validated(); - - $desiredPosition = Arr::get($validated, 'position') ?? $project->boardColumns()->count() + 1; - - $boardColumn = DB::transaction(function () use ($project, $validated, $desiredPosition): BoardColumn { - $boardColumn = $project->boardColumns()->create([ - 'name' => Arr::get($validated, 'name'), - 'position' => $project->boardColumns()->count() + 1, - ]); - $boardColumn->ticketStatuses()->create([ - 'name' => Arr::get($validated, 'name'), - 'position' => 1, - 'project_id' => $project->id, - ]); - - $this->reorderColumns($project, $boardColumn, $desiredPosition); - - return $boardColumn; - }); + $boardColumn = $this->storeBoardColumn->handle($project, $request->validated()); return to_route('projects.settings.board', $project) ->with('status', "{$boardColumn->name} was added to the board workflow."); @@ -99,15 +75,7 @@ class ProjectBoardSettingsController extends Controller $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); - $validated = $request->validated(); - - DB::transaction(function () use ($boardColumn, $project, $validated): void { - $boardColumn->update([ - 'name' => Arr::get($validated, 'name'), - ]); - - $this->reorderColumns($project, $boardColumn, Arr::get($validated, 'position')); - }); + $boardColumn = $this->updateBoardColumn->handle($project, $boardColumn, $request->validated()); return to_route('projects.settings.board', $project) ->with('status', "{$boardColumn->fresh()->name} was updated."); @@ -123,22 +91,7 @@ class ProjectBoardSettingsController extends Controller 403, ); - $deletedName = $boardColumn->name; - - DB::transaction(function () use ($boardColumn, $project): void { - $boardColumn->ticketStatuses() - ->get() - ->each(function (TicketStatus $ticketStatus) use ($project): void { - $ticketStatus->update([ - 'board_column_id' => null, - 'position' => $this->nextStatusPosition($project, null), - ]); - }); - - $boardColumn->delete(); - - $this->normalizeColumnPositions($project); - }); + $deletedName = $this->deleteBoardColumn->handle($project, $boardColumn); return to_route('projects.settings.board', $project) ->with('status', "{$deletedName} was removed from the board workflow."); @@ -152,19 +105,7 @@ class ProjectBoardSettingsController extends Controller $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); - $validated = $request->validated(); - $boardColumnId = Arr::get($validated, 'board_column_id'); - $boardColumnId = $boardColumnId === null ? null : (int) $boardColumnId; - - $ticketStatus = DB::transaction(function () use ($boardColumnId, $project, $validated): TicketStatus { - return $project->ticketStatuses()->create([ - 'board_column_id' => $boardColumnId, - 'name' => Arr::get($validated, 'name'), - 'position' => $this->nextStatusPosition($project, $boardColumnId), - ]); - }); - - $ticketStatus->load('boardColumn'); + $ticketStatus = $this->storeTicketStatus->handle($project, $request->validated()); return to_route('projects.settings.board', $project) ->with('status', "{$ticketStatus->name} was added to {$this->statusContextLabel($ticketStatus)}."); @@ -179,11 +120,7 @@ class ProjectBoardSettingsController extends Controller $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); - $validated = $request->validated(); - - $ticketStatus->update([ - 'name' => Arr::get($validated, 'name'), - ]); + $ticketStatus = $this->updateTicketStatus->handle($ticketStatus, $request->validated()); return to_route('projects.settings.board', $project) ->with('status', "{$ticketStatus->fresh()->name} was updated."); @@ -199,35 +136,10 @@ class ProjectBoardSettingsController extends Controller 403, ); - $ticketStatus->load('boardColumn'); - $boardColumnId = $ticketStatus->board_column_id; - - if ($this->statusContextQuery($project, $boardColumnId)->count() <= 1) { - return to_route('projects.settings.board', $project) - ->withErrors([ - 'ticket_status_id' => "{$this->statusContextLabel($ticketStatus)} needs at least one ticket status.", - ]); - } - - $deletedName = $ticketStatus->name; - $contextLabel = $this->statusContextLabel($ticketStatus); - - DB::transaction(function () use ($boardColumnId, $project, $ticketStatus): void { - $replacementStatus = $this->statusContextQuery($project, $boardColumnId) - ->whereKeyNot($ticketStatus->id) - ->firstOrFail(); - - $ticketStatus->tickets()->update([ - 'ticket_status_id' => $replacementStatus->id, - ]); - - $ticketStatus->delete(); - - $this->normalizeStatusPositions($project, $boardColumnId); - }); + $result = $this->deleteTicketStatus->handle($project, $ticketStatus); return to_route('projects.settings.board', $project) - ->with('status', "{$deletedName} was removed from {$contextLabel}."); + ->with('status', "{$result['deleted_name']} was removed from {$result['context_label']}."); } public function updateStatusTransitions( @@ -239,92 +151,14 @@ class ProjectBoardSettingsController extends Controller $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); - $validated = $request->validated(); - $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) - ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) - ->unique() - ->values() - ->all(); - - $ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds); + $this->updateTicketStatusTransitions->handle($ticketStatus, $request->validated()); return to_route('projects.settings.board', $project) ->with('status', "{$ticketStatus->name} move targets were updated."); } - private function normalizeColumnPositions(Project $project): void - { - $this->persistOrderedColumns( - $project->boardColumns() - ->get() - ->values(), - ); - } - - private function reorderColumns(Project $project, BoardColumn $movingColumn, int $desiredPosition): void - { - /** @var Collection $columns */ - $columns = $project->boardColumns() - ->whereKeyNot($movingColumn->id) - ->get() - ->values(); - - $insertAt = max(0, min($desiredPosition - 1, $columns->count())); - - $columns->splice($insertAt, 0, [$movingColumn]); - - $this->persistOrderedColumns($columns->values()); - } - - /** - * @param Collection $columns - */ - private function persistOrderedColumns(Collection $columns): void - { - $temporaryOffset = ((int) $columns->max('position')) + $columns->count() + 1; - - $columns->each(function (BoardColumn $column, int $index) use ($temporaryOffset): void { - $column->forceFill([ - 'position' => $temporaryOffset + $index, - ])->saveQuietly(); - }); - - $columns->each(function (BoardColumn $column, int $index): void { - $column->forceFill([ - 'position' => $index + 1, - ])->saveQuietly(); - }); - } - - private function nextStatusPosition(Project $project, ?int $boardColumnId): int - { - return $this->statusContextQuery($project, $boardColumnId)->count() + 1; - } - - private function normalizeStatusPositions(Project $project, ?int $boardColumnId): void - { - $this->statusContextQuery($project, $boardColumnId) - ->get() - ->values() - ->each(function (TicketStatus $ticketStatus, int $index): void { - $ticketStatus->forceFill([ - 'position' => $index + 1, - ])->saveQuietly(); - }); - } - private function statusContextLabel(TicketStatus $ticketStatus): string { return $ticketStatus->boardColumn?->name ?? 'Backlog'; } - - private function statusContextQuery(Project $project, ?int $boardColumnId): HasMany - { - return $project->ticketStatuses() - ->when( - $boardColumnId === null, - fn ($query) => $query->whereNull('board_column_id'), - fn ($query) => $query->where('board_column_id', $boardColumnId), - ); - } } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index edee943..d6405bb 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -3,7 +3,10 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; -use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; +use App\Actions\Projects\BuildProjectBacklogPayload; +use App\Actions\Projects\CreateProject; +use App\Actions\Projects\DeleteProject; +use App\Actions\Projects\ListVisibleProjects; use App\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; use App\Http\Requests\StoreProjectRequest; @@ -11,14 +14,9 @@ use App\Http\Requests\SyncProjectPermissionsRequest; use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\ProjectResource; use App\Http\Resources\TeamResource; -use App\Http\Resources\TicketPriorityResource; -use App\Http\Resources\TicketResource; -use App\Http\Resources\TicketStatusResource; -use App\Http\Resources\TicketTypeResource; use App\Http\Resources\UserResource; use App\Models\Project; use App\Models\Team; -use App\Models\Ticket; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -29,7 +27,10 @@ use Inertia\Response; class ProjectController extends Controller { public function __construct( - private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, + private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload, + private readonly CreateProject $createProject, + private readonly DeleteProject $deleteProject, + private readonly ListVisibleProjects $listVisibleProjects, private readonly PermissionService $permissionService, ) {} @@ -37,24 +38,8 @@ class ProjectController extends Controller { $user = $request->user(); - $projectQuery = Project::query() - ->orderByDesc('id'); - - if (! $this->permissionService->allows($user, Permission::GlobalAdmin)) { - $viewableProjectIds = $this->permissionService->viewableProjectIds($user); - - if ($viewableProjectIds === []) { - return Inertia::render('projects/Index', [ - 'projects' => ProjectResource::collection(collect())->resolve(), - 'status' => session('status'), - ]); - } - - $projectQuery->whereIn('id', $viewableProjectIds); - } - return Inertia::render('projects/Index', [ - 'projects' => ProjectResource::collection($projectQuery->get())->resolve(), + 'projects' => ProjectResource::collection($this->listVisibleProjects->handle($user))->resolve(), 'status' => session('status'), ]); } @@ -71,9 +56,7 @@ class ProjectController extends Controller public function store(StoreProjectRequest $request): RedirectResponse { - $project = Project::query()->create($request->validated()); - $this->initializeProjectTicketMetadata->handle($project); - $this->permissionService->grant($request->user(), Permission::ProjectAdmin, $project); + $project = $this->createProject->handle($request->validated(), $request->user()); return to_route('projects.show', $project); } @@ -102,44 +85,10 @@ class ProjectController extends Controller 403, ); - $canManageTickets = $this->permissionService->allows($user, Permission::ProjectWrite, $project); - - return Inertia::render('projects/Backlog', [ - 'backlogTicketStatuses' => TicketStatusResource::collection( - $project->ticketStatuses() - ->whereNull('board_column_id') - ->get(), - )->resolve(), - 'canCreateTickets' => $canManageTickets, - 'canEditTickets' => $canManageTickets, - 'project' => ProjectResource::make($project)->resolve(), - 'status' => session('status'), - 'ticketStatusOptions' => TicketStatusResource::collection( - $project->ticketStatuses() - ->with('boardColumn') - ->get(), - )->resolve(), - 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), - 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), - 'tickets' => TicketResource::collection( - Ticket::query() - ->with([ - 'ticketPriority', - 'ticketStatus.allowedTargetStatuses.boardColumn', - 'ticketStatus.boardColumn', - 'ticketType', - ]) - ->where('project_id', $project->id) - ->whereHas('ticketStatus', fn ($query) => $query->whereNull('board_column_id')) - ->orderByDesc('sequence_number') - ->get(), - )->resolve(), - 'users' => UserResource::collection( - User::query() - ->orderBy('name') - ->get(), - )->resolve(), - ]); + return Inertia::render( + 'projects/Backlog', + $this->buildProjectBacklogPayload->handle($project, $user, session('status')), + ); } public function general(Request $request, Project $project): Response @@ -223,9 +172,7 @@ class ProjectController extends Controller { $request->validated(); - $projectName = $project->name; - - $project->delete(); + $projectName = $this->deleteProject->handle($project); return to_route('projects.index') ->with('status', "Project {$projectName} was deleted."); diff --git a/app/Http/Controllers/ProjectRepositorySettingsController.php b/app/Http/Controllers/ProjectRepositorySettingsController.php index 01e0e81..c95537c 100644 --- a/app/Http/Controllers/ProjectRepositorySettingsController.php +++ b/app/Http/Controllers/ProjectRepositorySettingsController.php @@ -2,33 +2,29 @@ namespace App\Http\Controllers; -use App\Actions\Git\Data\GitRepository; -use App\Actions\Git\GitProviderManager; use App\Actions\Permissions\PermissionService; +use App\Actions\Repositories\BuildProjectRepositorySettingsPayload; +use App\Actions\Repositories\DeleteProjectRepositoryConnection; +use App\Actions\Repositories\StoreProjectRepositoryConnection; +use App\Actions\Repositories\UpdateProjectRepositoryConnection; use App\Enums\Permission; use App\Http\Requests\StoreRepositoryConnectionRequest; use App\Http\Requests\UpdateRepositoryConnectionRequest; -use App\Http\Resources\AvailableGitRepositorySourceResource; -use App\Http\Resources\ProjectResource; -use App\Http\Resources\RepositoryConnectionResource; use App\Models\Project; use App\Models\RepositoryConnection; -use App\Models\User; -use App\Models\UserGitConnection; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; -use Illuminate\Support\Collection; -use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; -use Throwable; class ProjectRepositorySettingsController extends Controller { public function __construct( - private readonly GitProviderManager $gitProviderManager, + private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload, + private readonly DeleteProjectRepositoryConnection $deleteProjectRepositoryConnection, private readonly PermissionService $permissionService, + private readonly StoreProjectRepositoryConnection $storeProjectRepositoryConnection, + private readonly UpdateProjectRepositoryConnection $updateProjectRepositoryConnection, ) {} public function show(Request $request, Project $project): Response @@ -39,23 +35,10 @@ class ProjectRepositorySettingsController extends Controller 403, ); - $repositoryConnections = $project->repositoryConnections() - ->get() - ->each(function (RepositoryConnection $repositoryConnection) use ($user): void { - $repositoryConnection->setAttribute( - 'has_personal_access', - $this->hasPersonalAccess($user, $repositoryConnection), - ); - }); - - return Inertia::render('projects/settings/Repositories', [ - 'availableRepositorySources' => AvailableGitRepositorySourceResource::collection( - $this->availableRepositorySources($user), - )->resolve(), - 'project' => ProjectResource::make($project)->resolve(), - 'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(), - 'status' => session('status'), - ]); + return Inertia::render( + 'projects/settings/Repositories', + $this->buildProjectRepositorySettingsPayload->handle($project, $user, session('status')), + ); } public function store( @@ -68,19 +51,7 @@ class ProjectRepositorySettingsController extends Controller 403, ); - $validated = $request->validated(); - $userGitConnectionId = Arr::get($validated, 'user_git_connection_id'); - $providerRepositoryId = Arr::get($validated, 'provider_repository_id'); - - $repositoryAttributes = $this->repositoryAttributesForSelection( - $user, - $userGitConnectionId, - $providerRepositoryId, - ); - - $this->ensureRepositoryConnectionIsUnique($project, $repositoryAttributes); - - $repositoryConnection = $project->repositoryConnections()->create($repositoryAttributes); + $repositoryConnection = $this->storeProjectRepositoryConnection->handle($project, $user, $request->validated()); return to_route('projects.settings.repositories', $project) ->with('status', "{$repositoryConnection->repository_full_name} was linked to {$project->name}."); @@ -98,24 +69,13 @@ class ProjectRepositorySettingsController extends Controller 403, ); - $validated = $request->validated(); - $userGitConnectionId = Arr::get($validated, 'user_git_connection_id'); - $providerRepositoryId = Arr::get($validated, 'provider_repository_id'); - - $repositoryAttributes = $this->repositoryAttributesForSelection( - $user, - $userGitConnectionId, - $providerRepositoryId, - ); - - $this->ensureRepositoryConnectionIsUnique( + $repositoryConnection = $this->updateProjectRepositoryConnection->handle( $project, - $repositoryAttributes, $repositoryConnection, + $user, + $request->validated(), ); - $repositoryConnection->update($repositoryAttributes); - return to_route('projects.settings.repositories', $project) ->with('status', "{$repositoryConnection->fresh()->repository_full_name} connection was updated."); } @@ -130,178 +90,9 @@ class ProjectRepositorySettingsController extends Controller 403, ); - $repositoryLabel = $repositoryConnection->repository_full_name; - - $repositoryConnection->delete(); + $repositoryLabel = $this->deleteProjectRepositoryConnection->handle($repositoryConnection); return to_route('projects.settings.repositories', $project) ->with('status', "{$repositoryLabel} was removed from the project."); } - - /** - * @return array, - * user_git_connection_id: int - * }> - */ - private function availableRepositorySources(User $user): array - { - /** @var Collection $gitConnections */ - $gitConnections = $user->gitConnections() - ->get(); - - return $gitConnections - ->map(function (UserGitConnection $gitConnection): array { - try { - $repositories = collect( - $this->gitProviderManager - ->client($gitConnection->provider) - ->listRepositories($gitConnection->base_url, $gitConnection->api_token), - ) - ->map(fn (GitRepository $repository): array => [ - 'full_name' => $repository->fullName, - 'owner_name' => $repository->owner, - 'provider_repository_id' => $repository->providerRepositoryId, - 'repository_name' => $repository->name, - 'repository_url' => $repository->htmlUrl, - 'visibility_label' => $repository->isPrivate ? 'Private' : 'Public', - ]) - ->sortBy('full_name') - ->values() - ->all(); - - $error = null; - } catch (Throwable) { - $repositories = []; - $error = sprintf('%s could not load repositories from this Git connection right now.', config('app.name')); - } - - return [ - 'account_login' => $gitConnection->account_login, - 'account_name' => $gitConnection->account_name, - 'base_url' => $gitConnection->base_url, - 'error' => $error, - 'provider' => $gitConnection->provider->value, - 'provider_label' => $gitConnection->provider->label(), - 'repositories' => $repositories, - 'user_git_connection_id' => $gitConnection->id, - ]; - }) - ->values() - ->all(); - } - - /** - * @param array{ - * base_url: string, - * owner_name: string, - * provider: string, - * provider_repository_id: string, - * repository_full_name: string, - * repository_name: string, - * repository_url: string - * } $repositoryAttributes - */ - private function ensureRepositoryConnectionIsUnique( - Project $project, - array $repositoryAttributes, - ?RepositoryConnection $repositoryConnection = null, - ): void { - $query = $project->repositoryConnections() - ->where('provider', $repositoryAttributes['provider']) - ->where('base_url', $repositoryAttributes['base_url']) - ->where('provider_repository_id', $repositoryAttributes['provider_repository_id']); - - if ($repositoryConnection !== null) { - $query->whereKeyNot($repositoryConnection->id); - } - - if ($query->exists()) { - throw ValidationException::withMessages([ - 'provider_repository_id' => 'This repository is already linked to the project.', - ]); - } - } - - private function hasPersonalAccess(User $user, RepositoryConnection $repositoryConnection): bool - { - return $user->gitConnections() - ->where('provider', $repositoryConnection->provider) - ->where('base_url', $repositoryConnection->base_url) - ->exists(); - } - - /** - * @return array{ - * base_url: string, - * owner_name: string, - * provider: string, - * provider_repository_id: string, - * repository_full_name: string, - * repository_name: string, - * repository_url: string - * } - */ - private function repositoryAttributesForSelection( - User $user, - int $userGitConnectionId, - string $providerRepositoryId, - ): array { - $userGitConnection = $this->ownedUserGitConnection($user, $userGitConnectionId); - - $repository = $this->repositoryFromConnection( - $userGitConnection, - $providerRepositoryId, - ); - - if ($repository === null) { - throw ValidationException::withMessages([ - 'provider_repository_id' => 'The selected repository is not available through this Git connection.', - ]); - } - - return [ - 'base_url' => $userGitConnection->base_url, - 'owner_name' => $repository->owner, - 'provider' => $userGitConnection->provider, - 'provider_repository_id' => $repository->providerRepositoryId, - 'repository_full_name' => $repository->fullName, - 'repository_name' => $repository->name, - 'repository_url' => $repository->htmlUrl, - ]; - } - - private function ownedUserGitConnection(User $user, int $userGitConnectionId): UserGitConnection - { - $userGitConnection = UserGitConnection::query()->findOrFail($userGitConnectionId); - - abort_unless($userGitConnection->user_id === $user->id, 403); - - return $userGitConnection; - } - - private function repositoryFromConnection( - UserGitConnection $userGitConnection, - string $providerRepositoryId, - ): ?GitRepository { - return collect( - $this->gitProviderManager - ->client($userGitConnection->provider) - ->listRepositories($userGitConnection->base_url, $userGitConnection->api_token), - ) - ->first(fn (GitRepository $repository): bool => $repository->providerRepositoryId === $providerRepositoryId); - } } diff --git a/app/Http/Controllers/ProjectTicketBranchController.php b/app/Http/Controllers/ProjectTicketBranchController.php index a3f663b..17072fa 100644 --- a/app/Http/Controllers/ProjectTicketBranchController.php +++ b/app/Http/Controllers/ProjectTicketBranchController.php @@ -2,29 +2,23 @@ namespace App\Http\Controllers; -use App\Actions\Git\Data\GitBranch; -use App\Actions\Git\GitProviderManager; -use App\Actions\Git\ValidateGitBranchName; use App\Actions\Permissions\PermissionService; +use App\Actions\Tickets\CreateProjectTicketBranch; +use App\Actions\Tickets\ValidateProjectTicketBranch; use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketBranchRequest; use App\Http\Requests\ValidateProjectTicketBranchRequest; use App\Models\Project; -use App\Models\RepositoryConnection; use App\Models\Ticket; -use App\Models\UserGitConnection; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; -use Illuminate\Support\Arr; -use Illuminate\Validation\ValidationException; -use Throwable; class ProjectTicketBranchController extends Controller { public function __construct( - private readonly GitProviderManager $gitProviderManager, - private readonly ValidateGitBranchName $validateGitBranchName, + private readonly CreateProjectTicketBranch $createProjectTicketBranch, private readonly PermissionService $permissionService, + private readonly ValidateProjectTicketBranch $validateProjectTicketBranch, ) {} public function validate( @@ -39,65 +33,9 @@ class ProjectTicketBranchController extends Controller 403, ); - $validated = $request->validated(); - $repositoryConnectionId = Arr::get($validated, 'repository_connection_id'); - $branchName = trim((string) Arr::get($validated, 'branch_name')); - - $repositoryConnection = $this->projectRepositoryConnection( - $project, - $repositoryConnectionId, + return response()->json( + $this->validateProjectTicketBranch->handle($project, $ticket, $user, $request->validated()), ); - $userGitConnection = $this->matchingUserGitConnection( - $user->id, - $repositoryConnection, - ); - - if ($userGitConnection === null) { - return response()->json([ - 'is_valid' => false, - 'message' => 'Connect a matching Git connection in your user settings first.', - ]); - } - - $syntaxMessage = $this->validateGitBranchName->message($branchName); - - if ($syntaxMessage !== null) { - return response()->json([ - 'is_valid' => false, - 'message' => $syntaxMessage, - ]); - } - - try { - $branchAlreadyExists = collect( - $this->gitProviderManager - ->client($userGitConnection->provider) - ->listBranches( - $userGitConnection->api_token, - $repositoryConnection->toGitRepository(), - ), - ) - ->contains( - fn (GitBranch $branch): bool => $branch->name === $branchName, - ); - } catch (Throwable) { - return response()->json([ - 'is_valid' => false, - 'message' => sprintf('%s could not validate this branch name right now.', config('app.name')), - ]); - } - - if ($branchAlreadyExists) { - return response()->json([ - 'is_valid' => false, - 'message' => 'This branch name already exists in the selected repository.', - ]); - } - - return response()->json([ - 'is_valid' => true, - 'message' => "Branch name is available for {$ticket->ticket_key}.", - ]); } public function store( @@ -113,100 +51,12 @@ class ProjectTicketBranchController extends Controller 403, ); - $validated = $request->validated(); - $repositoryConnectionId = Arr::get($validated, 'repository_connection_id'); - $branchName = trim((string) Arr::get($validated, 'branch_name')); - - $repositoryConnection = $this->projectRepositoryConnection( - $project, - $repositoryConnectionId, - ); - $userGitConnection = $this->matchingUserGitConnection( - $user->id, - $repositoryConnection, - ); - - if ($userGitConnection === null) { - throw ValidationException::withMessages([ - 'repository_connection_id' => 'Connect a matching Git connection in your user settings first.', - ]); - } - - $syntaxMessage = $this->validateGitBranchName->message($branchName); - - if ($syntaxMessage !== null) { - throw ValidationException::withMessages([ - 'branch_name' => $syntaxMessage, - ]); - } - - $gitProvider = $this->gitProviderManager->client($userGitConnection->provider); - - try { - $branchAlreadyExists = collect( - $gitProvider->listBranches( - $userGitConnection->api_token, - $repositoryConnection->toGitRepository(), - ), - ) - ->contains( - fn (GitBranch $branch): bool => $branch->name === $branchName, - ); - } catch (Throwable) { - throw ValidationException::withMessages([ - 'branch_name' => sprintf('%s could not validate this branch name right now.', config('app.name')), - ]); - } - - if ($branchAlreadyExists) { - throw ValidationException::withMessages([ - 'branch_name' => 'This branch name already exists in the selected repository.', - ]); - } - - try { - $repository = $gitProvider->getRepository( - $userGitConnection->base_url, - $userGitConnection->api_token, - $repositoryConnection->owner_name, - $repositoryConnection->repository_name, - ); - - $gitProvider->createBranch( - $userGitConnection->base_url, - $userGitConnection->api_token, - $repositoryConnection->owner_name, - $repositoryConnection->repository_name, - $branchName, - $repository->defaultBranch, - ); - } catch (Throwable) { - throw ValidationException::withMessages([ - 'branch_name' => sprintf('%s could not create this branch right now.', config('app.name')), - ]); - } + $result = $this->createProjectTicketBranch->handle($project, $ticket, $user, $request->validated()); return to_route('projects.tickets.show', [$project, $ticket]) ->with( 'status', - "{$branchName} was created in {$repositoryConnection->repository_full_name}.", + "{$result['branch_name']} was created in {$result['repository_full_name']}.", ); } - - private function projectRepositoryConnection(Project $project, int $repositoryConnectionId): RepositoryConnection - { - return $project->repositoryConnections() - ->findOrFail($repositoryConnectionId); - } - - private function matchingUserGitConnection( - int $userId, - RepositoryConnection $repositoryConnection, - ): ?UserGitConnection { - return UserGitConnection::query() - ->where('user_id', $userId) - ->where('provider', $repositoryConnection->provider) - ->where('base_url', $repositoryConnection->base_url) - ->first(); - } } diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 9c3bb21..3dc1f78 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -2,32 +2,16 @@ namespace App\Http\Controllers; -use App\Actions\Git\BuildSuggestedTicketBranchName; -use App\Actions\Git\ResolveTicketBranchMatches; -use App\Actions\Git\ResolveTicketPullRequestMatches; use App\Actions\Permissions\PermissionService; +use App\Actions\Projects\BuildProjectTicketDetailPayload; +use App\Actions\Projects\BuildProjectTicketOptionsPayload; use App\Actions\Tickets\CreateProjectTicket; use App\Actions\Tickets\UpdateProjectTicket; use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketRequest; use App\Http\Requests\UpdateProjectTicketRequest; -use App\Http\Resources\ProjectResource; -use App\Http\Resources\TicketFieldDefinitionResource; -use App\Http\Resources\TicketLinkResource; -use App\Http\Resources\TicketLinkTypeResource; -use App\Http\Resources\TicketPriorityResource; -use App\Http\Resources\TicketReferenceResource; -use App\Http\Resources\TicketRepositoryBranchResource; -use App\Http\Resources\TicketRepositoryPullRequestResource; -use App\Http\Resources\TicketResource; -use App\Http\Resources\TicketStatusResource; -use App\Http\Resources\TicketTreeNodeResource; -use App\Http\Resources\TicketTypeResource; -use App\Http\Resources\UserResource; use App\Models\Project; use App\Models\Ticket; -use App\Models\TicketLink; -use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -37,11 +21,10 @@ use Inertia\Response; class ProjectTicketController extends Controller { public function __construct( - private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName, + private readonly BuildProjectTicketDetailPayload $buildProjectTicketDetailPayload, + private readonly BuildProjectTicketOptionsPayload $buildProjectTicketOptionsPayload, private readonly CreateProjectTicket $createProjectTicket, private readonly PermissionService $permissionService, - private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches, - private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches, private readonly UpdateProjectTicket $updateProjectTicket, ) {} @@ -54,33 +37,14 @@ class ProjectTicketController extends Controller 403, ); - $source = Arr::get($request->query(), 'source') === 'board' - ? 'board' - : 'backlog'; - $defaultTicketStatus = $source === 'board' - ? $project->ticketStatuses()->whereNotNull('board_column_id')->first() - : $project->ticketStatuses()->whereNull('board_column_id')->first(); - - $defaultTicketStatus ??= $project->ticketStatuses()->first(); - - return Inertia::render('projects/TicketCreate', [ - 'defaultTicketStatusId' => $defaultTicketStatus?->id, - 'project' => ProjectResource::make($project)->resolve(), - 'source' => $source, - 'status' => session('status'), - 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), - 'ticketStatusOptions' => TicketStatusResource::collection( - $project->ticketStatuses() - ->with('boardColumn') - ->get(), - )->resolve(), - 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), - 'users' => UserResource::collection( - User::query() - ->orderBy('name') - ->get(), - )->resolve(), - ]); + return Inertia::render( + 'projects/TicketCreate', + $this->buildProjectTicketOptionsPayload->handle( + $project, + Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog', + session('status'), + ), + ); } public function store(StoreProjectTicketRequest $request, Project $project): RedirectResponse @@ -111,88 +75,10 @@ class ProjectTicketController extends Controller 403, ); - $canEditTicket = $this->permissionService->allows($user, Permission::ProjectWrite, $project); - $canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project); - $displayTicket = $ticket->load([ - 'assignee', - 'author', - 'fieldValues.ticketFieldDefinition', - 'fieldValues.userValue', - 'parentTicket', - 'ticketPriority', - 'ticketStatus.boardColumn', - 'ticketType', - 'worklogEntries.actor', - ]); - - if (! $project->show_author && ! $canManageProject) { - $displayTicket->setRelation('author', null); - $displayTicket->author_user_id = null; - } - - return Inertia::render('projects/Ticket', [ - 'canEditTicket' => $canEditTicket, - 'canManageProject' => $canManageProject, - 'childTicketTree' => TicketTreeNodeResource::collection( - $ticket->childTickets() - ->with('ticketType') - ->get(), - )->resolve(), - 'linkedTickets' => TicketLinkResource::collection( - TicketLink::query() - ->with(['sourceTicket', 'targetTicket', 'ticketLinkType']) - ->where(function ($query) use ($ticket): void { - $query - ->where('source_ticket_id', $ticket->id) - ->orWhere('target_ticket_id', $ticket->id); - }) - ->orderBy('id') - ->get() - ->each(fn (TicketLink $ticketLink) => $ticketLink->setAttribute('current_ticket_id', $ticket->id)), - )->resolve(), - 'parentTicketOptions' => TicketReferenceResource::collection( - Ticket::query() - ->where('project_id', $project->id) - ->whereKeyNot($ticket->id) - ->whereHas('ticketType', fn ($query) => $query->where('is_parent', true)) - ->orderByDesc('sequence_number') - ->get(), - )->resolve(), - 'project' => ProjectResource::make($project)->resolve(), - 'repositoryBranches' => TicketRepositoryBranchResource::collection( - $this->resolveTicketBranchMatches->handle( - $user, - $project, - $ticket, - ), - )->resolve(), - 'repositoryPullRequests' => TicketRepositoryPullRequestResource::collection( - $this->resolveTicketPullRequestMatches->handle( - $user, - $project, - $ticket, - ), - )->resolve(), - 'status' => session('status'), - 'suggestedBranchName' => $this->buildSuggestedTicketBranchName->handle($ticket), - 'ticket' => TicketResource::make($displayTicket)->resolve(), - 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), - 'ticketLinkOptions' => TicketReferenceResource::collection( - Ticket::query() - ->where('project_id', $project->id) - ->whereKeyNot($ticket->id) - ->orderByDesc('sequence_number') - ->get(), - )->resolve(), - 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), - 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), - 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), - 'users' => UserResource::collection( - User::query() - ->orderBy('name') - ->get(), - )->resolve(), - ]); + return Inertia::render( + 'projects/Ticket', + $this->buildProjectTicketDetailPayload->handle($project, $ticket, $user, session('status')), + ); } public function update( diff --git a/app/Http/Controllers/ProjectTicketLinkController.php b/app/Http/Controllers/ProjectTicketLinkController.php index 9220d20..208b98f 100644 --- a/app/Http/Controllers/ProjectTicketLinkController.php +++ b/app/Http/Controllers/ProjectTicketLinkController.php @@ -3,23 +3,22 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; -use App\Actions\Tickets\CreateTicketWorklogEntry; +use App\Actions\Tickets\DeleteProjectTicketLink; +use App\Actions\Tickets\StoreProjectTicketLink; use App\Enums\Permission; -use App\Enums\TicketWorklogEntryType; use App\Http\Requests\StoreTicketLinkRequest; use App\Models\Project; use App\Models\Ticket; use App\Models\TicketLink; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; -use Illuminate\Support\Facades\DB; class ProjectTicketLinkController extends Controller { public function __construct( - private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, + private readonly DeleteProjectTicketLink $deleteProjectTicketLink, private readonly PermissionService $permissionService, + private readonly StoreProjectTicketLink $storeProjectTicketLink, ) {} public function store( @@ -34,53 +33,7 @@ class ProjectTicketLinkController extends Controller 403, ); - $validated = $request->validated(); - $linkedTicketId = Arr::get($validated, 'linked_ticket_id'); - $ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id'); - - $linkedTicket = Ticket::query() - ->where('project_id', $project->id) - ->findOrFail($linkedTicketId); - - [$sourceTicketId, $targetTicketId] = $this->normalizeTicketPair($ticket->id, $linkedTicket->id); - - $ticketLink = DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $sourceTicketId, $targetTicketId, $ticket): TicketLink { - $ticketLink = TicketLink::query()->updateOrCreate( - [ - 'source_ticket_id' => $sourceTicketId, - 'target_ticket_id' => $targetTicketId, - ], - [ - 'ticket_link_type_id' => $ticketLinkTypeId, - ], - ); - - $ticketLink->load('ticketLinkType'); - - $this->createTicketWorklogEntry->handle( - $ticket, - $user, - TicketWorklogEntryType::LinkUpdated, - "Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.", - [ - 'linked_ticket_key' => $linkedTicket->ticket_key, - 'ticket_link_type' => $ticketLink->ticketLinkType->label, - ], - ); - - $this->createTicketWorklogEntry->handle( - $linkedTicket, - $user, - TicketWorklogEntryType::LinkUpdated, - "Linked {$ticket->ticket_key} as {$ticketLink->ticketLinkType->label}.", - [ - 'linked_ticket_key' => $ticket->ticket_key, - 'ticket_link_type' => $ticketLink->ticketLinkType->label, - ], - ); - - return $ticketLink; - }); + $ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated()); return to_route('projects.tickets.show', [$project, $ticket]) ->with('status', "{$ticketLink->ticketLinkType->label} link was saved."); @@ -99,57 +52,9 @@ class ProjectTicketLinkController extends Controller 403, ); - $ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']); - - abort_unless( - $ticketLink->source_ticket_id === $ticket->id || $ticketLink->target_ticket_id === $ticket->id, - 404, - ); - - $linkedTicket = $ticketLink->source_ticket_id === $ticket->id - ? $ticketLink->targetTicket - : $ticketLink->sourceTicket; - - $label = $ticketLink->ticketLinkType->label; - - DB::transaction(function () use ($label, $linkedTicket, $user, $ticket, $ticketLink): void { - $ticketLink->delete(); - - $this->createTicketWorklogEntry->handle( - $ticket, - $user, - TicketWorklogEntryType::LinkRemoved, - "Removed {$label} link to {$linkedTicket->ticket_key}.", - [ - 'linked_ticket_key' => $linkedTicket->ticket_key, - 'ticket_link_type' => $label, - ], - ); - - $this->createTicketWorklogEntry->handle( - $linkedTicket, - $user, - TicketWorklogEntryType::LinkRemoved, - "Removed {$label} link to {$ticket->ticket_key}.", - [ - 'linked_ticket_key' => $ticket->ticket_key, - 'ticket_link_type' => $label, - ], - ); - }); + $label = $this->deleteProjectTicketLink->handle($ticket, $ticketLink, $user); return to_route('projects.tickets.show', [$project, $ticket]) ->with('status', "{$label} link was removed."); } - - /** - * @return array{0: int, 1: int} - */ - private function normalizeTicketPair(int $firstTicketId, int $secondTicketId): array - { - return [ - min($firstTicketId, $secondTicketId), - max($firstTicketId, $secondTicketId), - ]; - } } diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index d4d7eb0..8afe652 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -3,9 +3,21 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; -use App\Actions\Tickets\CreateTicketWorklogEntry; +use App\Actions\Projects\BuildProjectTicketMetadataSettingsPayload; +use App\Actions\ProjectTicketMetadata\DeleteProjectTicketPriority; +use App\Actions\ProjectTicketMetadata\DeleteProjectTicketType; +use App\Actions\ProjectTicketMetadata\DeleteTicketFieldDefinition; +use App\Actions\ProjectTicketMetadata\DeleteTicketLinkType; +use App\Actions\ProjectTicketMetadata\StoreProjectTicketPriority; +use App\Actions\ProjectTicketMetadata\StoreProjectTicketType; +use App\Actions\ProjectTicketMetadata\StoreTicketFieldDefinition; +use App\Actions\ProjectTicketMetadata\StoreTicketLinkType; +use App\Actions\ProjectTicketMetadata\UpdateProjectTicketMetadataSettings as UpdateProjectTicketMetadataSettingsAction; +use App\Actions\ProjectTicketMetadata\UpdateProjectTicketPriority; +use App\Actions\ProjectTicketMetadata\UpdateProjectTicketType; +use App\Actions\ProjectTicketMetadata\UpdateTicketFieldDefinition; +use App\Actions\ProjectTicketMetadata\UpdateTicketLinkType; use App\Enums\Permission; -use App\Enums\TicketWorklogEntryType; use App\Http\Requests\DestroyProjectTicketTypeRequest; use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreProjectTicketTypeRequest; @@ -14,60 +26,51 @@ use App\Http\Requests\UpdateProjectNamedSettingRequest; use App\Http\Requests\UpdateProjectTicketMetadataSettings; use App\Http\Requests\UpdateProjectTicketTypeRequest; use App\Http\Requests\UpdateTicketFieldDefinitionRequest; -use App\Http\Resources\FieldTypeOptionResource; -use App\Http\Resources\ProjectResource; -use App\Http\Resources\TicketFieldDefinitionResource; -use App\Http\Resources\TicketLinkTypeResource; -use App\Http\Resources\TicketPriorityResource; -use App\Http\Resources\TicketTypeResource; use App\Models\Project; -use App\Models\Ticket; use App\Models\TicketFieldDefinition; use App\Models\TicketLinkType; use App\Models\TicketPriority; use App\Models\TicketType; -use Illuminate\Database\Eloquent\Model; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; -use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; class ProjectTicketMetadataSettingsController extends Controller { public function __construct( - private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, + private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload, + private readonly DeleteProjectTicketPriority $deleteProjectTicketPriority, + private readonly DeleteProjectTicketType $deleteProjectTicketType, + private readonly DeleteTicketFieldDefinition $deleteTicketFieldDefinition, + private readonly DeleteTicketLinkType $deleteTicketLinkType, private readonly PermissionService $permissionService, + private readonly StoreProjectTicketPriority $storeProjectTicketPriority, + private readonly StoreProjectTicketType $storeProjectTicketType, + private readonly StoreTicketFieldDefinition $storeTicketFieldDefinition, + private readonly StoreTicketLinkType $storeTicketLinkType, + private readonly UpdateProjectTicketMetadataSettingsAction $updateProjectTicketMetadataSettings, + private readonly UpdateProjectTicketPriority $updateProjectTicketPriority, + private readonly UpdateProjectTicketType $updateProjectTicketType, + private readonly UpdateTicketFieldDefinition $updateTicketFieldDefinition, + private readonly UpdateTicketLinkType $updateTicketLinkType, ) {} public function show(Request $request, Project $project): Response { $this->abortUnlessCanManage($request, $project); - return Inertia::render('projects/settings/Metadata', [ - 'fieldTypeOptions' => FieldTypeOptionResource::collection(collect(TicketFieldDefinition::FIELD_TYPES))->resolve(), - 'project' => ProjectResource::make($project)->resolve(), - 'error' => session('error'), - 'status' => session('status'), - 'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(), - 'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(), - 'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(), - 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(), - ]); + return Inertia::render( + 'projects/settings/Metadata', + $this->buildProjectTicketMetadataSettingsPayload->handle($project, session('status'), session('error')), + ); } public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $validated = $request->validated(); - - $project->update([ - 'show_author' => Arr::get($validated, 'show_author', false), - 'show_story_points' => Arr::get($validated, 'show_story_points', false), - ]); + $this->updateProjectTicketMetadataSettings->handle($project, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', 'Settings have been updated.'); @@ -77,23 +80,7 @@ class ProjectTicketMetadataSettingsController extends Controller { $this->abortUnlessCanManage($request, $project); - $validated = $request->validated(); - - $ticketType = DB::transaction(function () use ($project, $validated): TicketType { - $ticketType = $project->ticketTypes()->create([ - 'name' => Arr::get($validated, 'name'), - 'position' => $project->ticketTypes()->count() + 1, - 'is_parent' => Arr::get($validated, 'is_parent', false), - ]); - - $this->reorderModels( - $project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(), - $ticketType, - Arr::get($validated, 'position') ?? $project->ticketTypes()->count(), - ); - - return $ticketType; - }); + $ticketType = $this->storeProjectTicketType->handle($project, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', "{$ticketType->name} type was added."); @@ -106,19 +93,7 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $validated = $request->validated(); - - DB::transaction(function () use ($project, $validated, $ticketType): void { - $ticketType->update([ - 'name' => Arr::get($validated, 'name'), - ]); - - $this->reorderModels( - $project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(), - $ticketType, - Arr::get($validated, 'position'), - ); - }); + $ticketType = $this->updateProjectTicketType->handle($project, $ticketType, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', "{$ticketType->fresh()->name} type was updated."); @@ -131,78 +106,28 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - if ($project->ticketTypes()->count() === 1) { + $result = $this->deleteProjectTicketType->handle( + $project, + $ticketType, + $request->user(), + $request->validated(), + ); + + if (! $result['deleted']) { return to_route('projects.settings.metadata', $project) ->with('error', true) - ->with('status', 'The last ticket type cannot be deleted.'); + ->with('status', $result['message']); } - $deletedName = $ticketType->name; - $validated = $request->validated(); - - DB::transaction(function () use ($deletedName, $project, $request, $ticketType, $validated): void { - $replacementTicketTypeId = Arr::get($validated, 'replacement_ticket_type_id'); - - if ($replacementTicketTypeId !== null) { - $replacementTicketType = $project->ticketTypes() - ->findOrFail($replacementTicketTypeId); - - Ticket::query() - ->where('project_id', $project->id) - ->where('ticket_type_id', $ticketType->id) - ->with(['ticketType']) - ->get() - ->each(function (Ticket $ticket) use ($deletedName, $replacementTicketType, $request): void { - $ticket->update([ - 'ticket_type_id' => $replacementTicketType->id, - ]); - - $this->createTicketWorklogEntry->handle( - $ticket->fresh(), - $request->user(), - TicketWorklogEntryType::Updated, - "Updated type because {$deletedName} type was deleted.", - [ - 'ticket_type' => [ - 'from' => $deletedName, - 'to' => $replacementTicketType->name, - ], - ], - ); - }); - } - - $ticketType->delete(); - - $this->persistOrderedModels($project->ticketTypes()->get()->values()); - }); - return to_route('projects.settings.metadata', $project) - ->with('status', "{$deletedName} type was removed."); + ->with('status', $result['message']); } public function storePriority(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $validated = $request->validated(); - $name = Arr::get($validated, 'name'); - $position = Arr::get($validated, 'position'); - - $ticketPriority = DB::transaction(function () use ($project, $name, $position): TicketPriority { - $ticketPriority = $project->ticketPriorities()->create([ - 'name' => $name, - 'position' => $project->ticketPriorities()->count() + 1, - ]); - - $this->reorderModels( - $project->ticketPriorities()->whereKeyNot($ticketPriority->id)->get()->values(), - $ticketPriority, - $position ?? $project->ticketPriorities()->count(), - ); - - return $ticketPriority; - }); + $ticketPriority = $this->storeProjectTicketPriority->handle($project, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', "{$ticketPriority->name} priority was added."); @@ -215,21 +140,7 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $validated = $request->validated(); - $name = Arr::get($validated, 'name'); - $position = Arr::get($validated, 'position'); - - DB::transaction(function () use ($project, $name, $position, $ticketPriority): void { - $ticketPriority->update([ - 'name' => $name, - ]); - - $this->reorderModels( - $project->ticketPriorities()->whereKeyNot($ticketPriority->id)->get()->values(), - $ticketPriority, - $position, - ); - }); + $ticketPriority = $this->updateProjectTicketPriority->handle($project, $ticketPriority, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', "{$ticketPriority->fresh()->name} priority was updated."); @@ -242,22 +153,16 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - if ($project->ticketPriorities()->count() === 1) { + $result = $this->deleteProjectTicketPriority->handle($project, $ticketPriority); + + if (! $result['deleted']) { return to_route('projects.settings.metadata', $project) ->with('error', true) - ->with('status', 'The last ticket priority cannot be deleted.'); + ->with('status', $result['message']); } - $deletedName = $ticketPriority->name; - - DB::transaction(function () use ($project, $ticketPriority): void { - $ticketPriority->delete(); - - $this->persistOrderedModels($project->ticketPriorities()->get()->values()); - }); - return to_route('projects.settings.metadata', $project) - ->with('status', "{$deletedName} priority was removed."); + ->with('status', $result['message']); } public function storeFieldDefinition( @@ -266,23 +171,7 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $validated = $request->validated(); - - $ticketFieldDefinition = DB::transaction(function () use ($project, $validated): TicketFieldDefinition { - $ticketFieldDefinition = $project->ticketFieldDefinitions()->create([ - 'field_type' => Arr::get($validated, 'field_type'), - 'label' => Arr::get($validated, 'label'), - 'position' => $project->ticketFieldDefinitions()->count() + 1, - ]); - - $this->reorderModels( - $project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(), - $ticketFieldDefinition, - Arr::get($validated, 'position') ?? $project->ticketFieldDefinitions()->count(), - ); - - return $ticketFieldDefinition; - }); + $ticketFieldDefinition = $this->storeTicketFieldDefinition->handle($project, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', "{$ticketFieldDefinition->label} field was added."); @@ -295,20 +184,7 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $validated = $request->validated(); - - DB::transaction(function () use ($project, $ticketFieldDefinition, $validated): void { - $ticketFieldDefinition->update([ - 'field_type' => Arr::get($validated, 'field_type'), - 'label' => Arr::get($validated, 'label'), - ]); - - $this->reorderModels( - $project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(), - $ticketFieldDefinition, - Arr::get($validated, 'position'), - ); - }); + $ticketFieldDefinition = $this->updateTicketFieldDefinition->handle($project, $ticketFieldDefinition, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', "{$ticketFieldDefinition->fresh()->label} field was updated."); @@ -321,13 +197,7 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $deletedLabel = $ticketFieldDefinition->label; - - DB::transaction(function () use ($project, $ticketFieldDefinition): void { - $ticketFieldDefinition->delete(); - - $this->persistOrderedModels($project->ticketFieldDefinitions()->get()->values()); - }); + $deletedLabel = $this->deleteTicketFieldDefinition->handle($project, $ticketFieldDefinition); return to_route('projects.settings.metadata', $project) ->with('status', "{$deletedLabel} field was removed."); @@ -335,26 +205,9 @@ class ProjectTicketMetadataSettingsController extends Controller public function storeLinkType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse { - $validated = $request->validated(); - $name = Arr::get($validated, 'name'); - $position = Arr::get($validated, 'position'); - $this->abortUnlessCanManage($request, $project); - $ticketLinkType = DB::transaction(function () use ($project, $position, $name): TicketLinkType { - $ticketLinkType = $project->ticketLinkTypes()->create([ - 'label' => $name, - 'position' => $project->ticketLinkTypes()->count() + 1, - ]); - - $this->reorderModels( - $project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(), - $ticketLinkType, - $position ?? $project->ticketLinkTypes()->count(), - ); - - return $ticketLinkType; - }); + $ticketLinkType = $this->storeTicketLinkType->handle($project, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', "{$ticketLinkType->label} link type was added."); @@ -366,23 +219,9 @@ class ProjectTicketMetadataSettingsController extends Controller TicketLinkType $ticketLinkType, ): RedirectResponse { - $validated = $request->validated(); - $name = Arr::get($validated, 'name'); - $position = Arr::get($validated, 'position'); - $this->abortUnlessCanManage($request, $project); - DB::transaction(function () use ($project, $name, $position, $ticketLinkType): void { - $ticketLinkType->update([ - 'label' => $name, - ]); - - $this->reorderModels( - $project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(), - $ticketLinkType, - $position, - ); - }); + $ticketLinkType = $this->updateTicketLinkType->handle($project, $ticketLinkType, $request->validated()); return to_route('projects.settings.metadata', $project) ->with('status', "{$ticketLinkType->fresh()->label} link type was updated."); @@ -395,13 +234,7 @@ class ProjectTicketMetadataSettingsController extends Controller ): RedirectResponse { $this->abortUnlessCanManage($request, $project); - $deletedLabel = $ticketLinkType->label; - - DB::transaction(function () use ($project, $ticketLinkType): void { - $ticketLinkType->delete(); - - $this->persistOrderedModels($project->ticketLinkTypes()->get()->values()); - }); + $deletedLabel = $this->deleteTicketLinkType->handle($project, $ticketLinkType); return to_route('projects.settings.metadata', $project) ->with('status', "{$deletedLabel} link type was removed."); @@ -414,36 +247,4 @@ class ProjectTicketMetadataSettingsController extends Controller 403, ); } - - /** - * @param Collection $models - */ - private function persistOrderedModels(Collection $models): void - { - $temporaryOffset = ((int) $models->max('position')) + $models->count() + 1; - - $models->each(function (Model $model, int $index) use ($temporaryOffset): void { - $model->forceFill([ - 'position' => $temporaryOffset + $index, - ])->saveQuietly(); - }); - - $models->each(function (Model $model, int $index): void { - $model->forceFill([ - 'position' => $index + 1, - ])->saveQuietly(); - }); - } - - /** - * @param Collection $existingModels - */ - private function reorderModels(Collection $existingModels, Model $movingModel, int $desiredPosition): void - { - $insertAt = max(0, min($desiredPosition - 1, $existingModels->count())); - - $existingModels->splice($insertAt, 0, [$movingModel]); - - $this->persistOrderedModels($existingModels->values()); - } } diff --git a/app/Http/Controllers/Settings/GitConnectionController.php b/app/Http/Controllers/Settings/GitConnectionController.php index 7e4d0a2..7f8908a 100644 --- a/app/Http/Controllers/Settings/GitConnectionController.php +++ b/app/Http/Controllers/Settings/GitConnectionController.php @@ -2,8 +2,9 @@ namespace App\Http\Controllers\Settings; -use App\Actions\Git\GitProviderKey; -use App\Actions\Git\GitProviderManager; +use App\Actions\UserGitConnections\DeleteUserGitConnection; +use App\Actions\UserGitConnections\StoreUserGitConnection; +use App\Actions\UserGitConnections\UpdateUserGitConnection; use App\Http\Controllers\Controller; use App\Http\Requests\Settings\StoreUserGitConnectionRequest; use App\Http\Requests\Settings\UpdateUserGitConnectionRequest; @@ -11,15 +12,15 @@ use App\Http\Resources\UserGitConnectionResource; use App\Models\UserGitConnection; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; use Inertia\Inertia; use Inertia\Response; -use Throwable; class GitConnectionController extends Controller { public function __construct( - private readonly GitProviderManager $gitProviderManager, + private readonly DeleteUserGitConnection $deleteUserGitConnection, + private readonly StoreUserGitConnection $storeUserGitConnection, + private readonly UpdateUserGitConnection $updateUserGitConnection, ) {} public function edit(Request $request): Response @@ -33,35 +34,7 @@ class GitConnectionController extends Controller public function store(StoreUserGitConnectionRequest $request): RedirectResponse { - $validated = $request->validated(); - - $apiToken = Arr::get($validated, 'api_token'); - $baseUrl = Arr::get($validated, 'base_url'); - $provider = GitProviderKey::from(Arr::get($validated, 'provider')); - $user = $request->user(); - - try { - $gitProvider = $this->gitProviderManager->client($provider); - $authenticatedUser = $gitProvider->getAuthenticatedUser( - $baseUrl, - $apiToken, - ); - } catch (Throwable) { - return back() - ->withErrors([ - 'api_token' => sprintf('%s could not authenticate against Gitea with this base URL and token.', config('app.name')), - ]); - } - - UserGitConnection::query()->create([ - 'account_login' => $authenticatedUser->login, - 'account_name' => $authenticatedUser->name, - 'api_token' => $apiToken, - 'base_url' => $baseUrl, - 'provider' => $provider, - 'provider_user_id' => $authenticatedUser->providerUserId, - 'user_id' => $user->id, - ]); + $this->storeUserGitConnection->handle($request->user(), $request->validated()); return to_route('git-connections.edit') ->with('status', 'Git connection was added.'); @@ -73,32 +46,7 @@ class GitConnectionController extends Controller ): RedirectResponse { $this->abortUnlessOwner($request, $gitConnection); - $validated = $request->validated(); - $apiToken = Arr::get($validated, 'api_token'); - $baseUrl = Arr::get($validated, 'base_url'); - $provider = GitProviderKey::from(Arr::get($validated, 'provider')); - - try { - $gitProvider = $this->gitProviderManager->client($provider); - $authenticatedUser = $gitProvider->getAuthenticatedUser( - $baseUrl, - $apiToken, - ); - } catch (Throwable) { - return back() - ->withErrors([ - 'api_token' => sprintf('%s could not authenticate against Gitea with this base URL and token.', config('app.name')), - ]); - } - - $gitConnection->update([ - 'account_login' => $authenticatedUser->login, - 'account_name' => $authenticatedUser->name, - 'api_token' => $apiToken, - 'base_url' => $baseUrl, - 'provider' => $provider, - 'provider_user_id' => $authenticatedUser->providerUserId, - ]); + $this->updateUserGitConnection->handle($gitConnection, $request->validated()); return to_route('git-connections.edit') ->with('status', 'Git connection was updated.'); @@ -108,9 +56,7 @@ class GitConnectionController extends Controller { $this->abortUnlessOwner($request, $gitConnection); - $label = "{$gitConnection->account_login} @ {$gitConnection->base_url}"; - - $gitConnection->delete(); + $label = $this->deleteUserGitConnection->handle($gitConnection); return to_route('git-connections.edit') ->with('status', "{$label} was removed."); diff --git a/app/Http/Requests/Api/MoveProjectTicketRequest.php b/app/Http/Requests/Api/MoveProjectTicketRequest.php new file mode 100644 index 0000000..6f87219 --- /dev/null +++ b/app/Http/Requests/Api/MoveProjectTicketRequest.php @@ -0,0 +1,72 @@ +user() !== null; + } + + /** + * @return array|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'ticket_status_id' => [ + 'required', + 'integer', + Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), + ], + ]; + } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + /** @var Ticket $ticket */ + $ticket = $this->route('ticket'); + $validated = $validator->validated(); + $ticketStatusId = (int) Arr::get($validated, 'ticket_status_id'); + + if ($ticket->ticket_status_id === $ticketStatusId) { + return; + } + + $ticket->loadMissing('ticketStatus'); + + $isAllowed = $ticket->ticketStatus?->allowedTargetStatuses() + ->whereKey($ticketStatusId) + ->exists() ?? false; + + if (! $isAllowed) { + $validator->errors()->add( + 'ticket_status_id', + 'Choose a status this ticket is allowed to move to.', + ); + } + }, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index d68d267..efb547c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -9,8 +9,9 @@ use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( + api: __DIR__.'/../routes/api.php', web: __DIR__.'/../routes/web.php', - commands: __DIR__ . '/../routes/console.php', + commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { diff --git a/docs/openapi/kanbangit-api.openapi.json b/docs/openapi/kanbangit-api.openapi.json new file mode 100644 index 0000000..1d6dca8 --- /dev/null +++ b/docs/openapi/kanbangit-api.openapi.json @@ -0,0 +1,2602 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "KanbanGit Application API", + "version": "0.1.0", + "description": "First-party JSON API for KanbanGit UI-equivalent workflows. Authentication is intentionally not described here because API verification will be added globally by the application later." + }, + "servers": [ + { + "url": "/api" + } + ], + "security": [], + "tags": [ + { + "name": "Projects" + }, + { + "name": "Tickets" + }, + { + "name": "Board" + }, + { + "name": "Metadata" + }, + { + "name": "Repositories" + }, + { + "name": "Git Connections" + } + ], + "paths": { + "/projects": { + "get": { + "tags": ["Projects"], + "operationId": "listProjects", + "responses": { + "200": { + "$ref": "#/components/responses/ProjectCollection" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + } + } + }, + "post": { + "tags": ["Projects"], + "operationId": "createProject", + "requestBody": { + "$ref": "#/components/requestBodies/ProjectStore" + }, + "responses": { + "201": { + "$ref": "#/components/responses/ProjectResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}": { + "get": { + "tags": ["Projects"], + "operationId": "getProject", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/ProjectResource" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "delete": { + "tags": ["Projects"], + "operationId": "deleteProject", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/DeleteProject" + }, + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/board": { + "get": { + "tags": ["Board"], + "operationId": "getProjectBoard", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/BoardPayload" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/projects/{project}/backlog": { + "get": { + "tags": ["Board"], + "operationId": "getProjectBacklog", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/BacklogPayload" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/projects/{project}/ticket-options": { + "get": { + "tags": ["Tickets"], + "operationId": "getProjectTicketOptions", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "name": "source", + "in": "query", + "schema": { + "type": "string", + "enum": ["backlog", "board"], + "default": "backlog" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/TicketOptionsPayload" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/projects/{project}/tickets": { + "get": { + "tags": ["Tickets"], + "operationId": "listProjectTickets", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/TicketCollection" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": ["Tickets"], + "operationId": "createProjectTicket", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketStore" + }, + "responses": { + "201": { + "$ref": "#/components/responses/TicketResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/tickets/{ticket}": { + "get": { + "tags": ["Tickets"], + "operationId": "getProjectTicket", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/TicketDetailPayload" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "patch": { + "tags": ["Tickets"], + "operationId": "updateProjectTicket", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketUpdate" + }, + "responses": { + "200": { + "$ref": "#/components/responses/TicketResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/tickets/{ticket}/status-targets": { + "get": { + "tags": ["Tickets"], + "operationId": "getProjectTicketStatusTargets", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/StatusTargetsPayload" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/projects/{project}/tickets/{ticket}/status": { + "patch": { + "tags": ["Tickets"], + "operationId": "moveProjectTicket", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketMove" + }, + "responses": { + "200": { + "$ref": "#/components/responses/TicketResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/tickets/{ticket}/branches/validate": { + "get": { + "tags": ["Tickets"], + "operationId": "validateProjectTicketBranch", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketId" + }, + { + "name": "repository_connection_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "branch_name", + "in": "query", + "required": true, + "schema": { + "type": "string", + "maxLength": 255 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/BranchValidationPayload" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/tickets/{ticket}/branches": { + "post": { + "tags": ["Tickets"], + "operationId": "createProjectTicketBranch", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/Branch" + }, + "responses": { + "201": { + "$ref": "#/components/responses/BranchCreatedPayload" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/tickets/{ticket}/links": { + "post": { + "tags": ["Tickets"], + "operationId": "createProjectTicketLink", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketLink" + }, + "responses": { + "201": { + "$ref": "#/components/responses/TicketLinkResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/tickets/{ticket}/links/{ticketLink}": { + "delete": { + "tags": ["Tickets"], + "operationId": "deleteProjectTicketLink", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketId" + }, + { + "$ref": "#/components/parameters/TicketLinkId" + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/projects/{project}/settings/board": { + "get": { + "tags": ["Board"], + "operationId": "getProjectBoardSettings", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/BoardSettingsPayload" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/projects/{project}/settings/board/columns": { + "post": { + "tags": ["Board"], + "operationId": "createProjectBoardColumn", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/BoardColumnStore" + }, + "responses": { + "201": { + "$ref": "#/components/responses/BoardColumnResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/board/columns/{boardColumn}": { + "patch": { + "tags": ["Board"], + "operationId": "updateProjectBoardColumn", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/BoardColumnId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/BoardColumnUpdate" + }, + "responses": { + "200": { + "$ref": "#/components/responses/BoardColumnResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "tags": ["Board"], + "operationId": "deleteProjectBoardColumn", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/BoardColumnId" + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + } + } + } + }, + "/projects/{project}/settings/board/statuses": { + "post": { + "tags": ["Board"], + "operationId": "createProjectTicketStatus", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketStatusStore" + }, + "responses": { + "201": { + "$ref": "#/components/responses/TicketStatusResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/board/statuses/{ticketStatus}": { + "patch": { + "tags": ["Board"], + "operationId": "updateProjectTicketStatus", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketStatusId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketStatusUpdate" + }, + "responses": { + "200": { + "$ref": "#/components/responses/TicketStatusResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "tags": ["Board"], + "operationId": "deleteProjectTicketStatus", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketStatusId" + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/board/statuses/{ticketStatus}/transitions": { + "patch": { + "tags": ["Board"], + "operationId": "updateProjectTicketStatusTransitions", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketStatusId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketStatusTransitions" + }, + "responses": { + "200": { + "$ref": "#/components/responses/TicketStatusResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/metadata": { + "get": { + "tags": ["Metadata"], + "operationId": "getProjectTicketMetadataSettings", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/MetadataSettingsPayload" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "patch": { + "tags": ["Metadata"], + "operationId": "updateProjectTicketMetadataSettings", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/MetadataSettings" + }, + "responses": { + "200": { + "$ref": "#/components/responses/ProjectResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/metadata/types": { + "post": { + "tags": ["Metadata"], + "operationId": "createProjectTicketType", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketTypeStore" + }, + "responses": { + "201": { + "$ref": "#/components/responses/TicketTypeResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/metadata/types/{ticketType}": { + "patch": { + "tags": ["Metadata"], + "operationId": "updateProjectTicketType", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketTypeId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketTypeUpdate" + }, + "responses": { + "200": { + "$ref": "#/components/responses/TicketTypeResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "tags": ["Metadata"], + "operationId": "deleteProjectTicketType", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketTypeId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/DeleteTicketType" + }, + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/metadata/priorities": { + "post": { + "tags": ["Metadata"], + "operationId": "createProjectTicketPriority", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/NamedSettingStore" + }, + "responses": { + "201": { + "$ref": "#/components/responses/TicketPriorityResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/metadata/priorities/{ticketPriority}": { + "patch": { + "tags": ["Metadata"], + "operationId": "updateProjectTicketPriority", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketPriorityId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/NamedSettingUpdate" + }, + "responses": { + "200": { + "$ref": "#/components/responses/TicketPriorityResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "tags": ["Metadata"], + "operationId": "deleteProjectTicketPriority", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketPriorityId" + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/metadata/fields": { + "post": { + "tags": ["Metadata"], + "operationId": "createProjectTicketFieldDefinition", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketFieldDefinitionStore" + }, + "responses": { + "201": { + "$ref": "#/components/responses/TicketFieldDefinitionResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/metadata/fields/{ticketFieldDefinition}": { + "patch": { + "tags": ["Metadata"], + "operationId": "updateProjectTicketFieldDefinition", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketFieldDefinitionId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/TicketFieldDefinitionUpdate" + }, + "responses": { + "200": { + "$ref": "#/components/responses/TicketFieldDefinitionResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "tags": ["Metadata"], + "operationId": "deleteProjectTicketFieldDefinition", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketFieldDefinitionId" + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + } + } + } + }, + "/projects/{project}/settings/metadata/link-types": { + "post": { + "tags": ["Metadata"], + "operationId": "createProjectTicketLinkType", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/NamedSettingStore" + }, + "responses": { + "201": { + "$ref": "#/components/responses/TicketLinkTypeResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/metadata/link-types/{ticketLinkType}": { + "patch": { + "tags": ["Metadata"], + "operationId": "updateProjectTicketLinkType", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketLinkTypeId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/NamedSettingUpdate" + }, + "responses": { + "200": { + "$ref": "#/components/responses/TicketLinkTypeResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "tags": ["Metadata"], + "operationId": "deleteProjectTicketLinkType", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/TicketLinkTypeId" + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + } + } + } + }, + "/projects/{project}/settings/repositories": { + "get": { + "tags": ["Repositories"], + "operationId": "getProjectRepositorySettings", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/RepositorySettingsPayload" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "tags": ["Repositories"], + "operationId": "createProjectRepositoryConnection", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/RepositorySelection" + }, + "responses": { + "201": { + "$ref": "#/components/responses/RepositoryConnectionResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/projects/{project}/settings/repositories/{repositoryConnection}": { + "patch": { + "tags": ["Repositories"], + "operationId": "updateProjectRepositoryConnection", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/RepositoryConnectionId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/RepositorySelection" + }, + "responses": { + "200": { + "$ref": "#/components/responses/RepositoryConnectionResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "tags": ["Repositories"], + "operationId": "deleteProjectRepositoryConnection", + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/RepositoryConnectionId" + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + } + } + } + }, + "/git-connections": { + "get": { + "tags": ["Git Connections"], + "operationId": "listGitConnections", + "responses": { + "200": { + "$ref": "#/components/responses/UserGitConnectionCollection" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + } + } + }, + "post": { + "tags": ["Git Connections"], + "operationId": "createGitConnection", + "requestBody": { + "$ref": "#/components/requestBodies/UserGitConnection" + }, + "responses": { + "201": { + "$ref": "#/components/responses/UserGitConnectionResource" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/git-connections/{gitConnection}": { + "patch": { + "tags": ["Git Connections"], + "operationId": "updateGitConnection", + "parameters": [ + { + "$ref": "#/components/parameters/GitConnectionId" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/UserGitConnection" + }, + "responses": { + "200": { + "$ref": "#/components/responses/UserGitConnectionResource" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "tags": ["Git Connections"], + "operationId": "deleteGitConnection", + "parameters": [ + { + "$ref": "#/components/parameters/GitConnectionId" + } + ], + "responses": { + "204": { + "$ref": "#/components/responses/NoContent" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + } + }, + "components": { + "parameters": { + "ProjectId": { + "name": "project", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "TicketId": { + "name": "ticket", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "TicketLinkId": { + "name": "ticketLink", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "BoardColumnId": { + "name": "boardColumn", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "TicketStatusId": { + "name": "ticketStatus", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "TicketTypeId": { + "name": "ticketType", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "TicketPriorityId": { + "name": "ticketPriority", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "TicketFieldDefinitionId": { + "name": "ticketFieldDefinition", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "TicketLinkTypeId": { + "name": "ticketLinkType", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "RepositoryConnectionId": { + "name": "repositoryConnection", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + "GitConnectionId": { + "name": "gitConnection", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + }, + "requestBodies": { + "ProjectStore": { + "$ref": "#/components/requestBodies/JsonProjectStore" + }, + "JsonProjectStore": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStoreRequest" + } + } + } + }, + "DeleteProject": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteProjectRequest" + } + } + } + }, + "TicketStore": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketStoreRequest" + } + } + } + }, + "TicketUpdate": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketUpdateRequest" + } + } + } + }, + "TicketMove": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketMoveRequest" + } + } + } + }, + "Branch": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchRequest" + } + } + } + }, + "TicketLink": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketLinkRequest" + } + } + } + }, + "BoardColumnStore": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BoardColumnStoreRequest" + } + } + } + }, + "BoardColumnUpdate": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BoardColumnUpdateRequest" + } + } + } + }, + "TicketStatusStore": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketStatusStoreRequest" + } + } + } + }, + "TicketStatusUpdate": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketStatusUpdateRequest" + } + } + } + }, + "TicketStatusTransitions": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketStatusTransitionsRequest" + } + } + } + }, + "MetadataSettings": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsRequest" + } + } + } + }, + "TicketTypeStore": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketTypeStoreRequest" + } + } + } + }, + "TicketTypeUpdate": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketTypeUpdateRequest" + } + } + } + }, + "DeleteTicketType": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTicketTypeRequest" + } + } + } + }, + "NamedSettingStore": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NamedSettingStoreRequest" + } + } + } + }, + "NamedSettingUpdate": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NamedSettingUpdateRequest" + } + } + } + }, + "TicketFieldDefinitionStore": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketFieldDefinitionStoreRequest" + } + } + } + }, + "TicketFieldDefinitionUpdate": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketFieldDefinitionUpdateRequest" + } + } + } + }, + "RepositorySelection": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySelectionRequest" + } + } + } + }, + "UserGitConnection": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserGitConnectionRequest" + } + } + } + } + }, + "responses": { + "ProjectResource": { + "description": "Project resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectEnvelope" + } + } + } + }, + "ProjectCollection": { + "description": "Project collection.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCollectionEnvelope" + } + } + } + }, + "TicketResource": { + "description": "Ticket resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketEnvelope" + } + } + } + }, + "TicketCollection": { + "description": "Ticket collection.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketCollectionEnvelope" + } + } + } + }, + "TicketLinkResource": { + "description": "Ticket link resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketLinkEnvelope" + } + } + } + }, + "BoardColumnResource": { + "description": "Board column resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BoardColumnEnvelope" + } + } + } + }, + "TicketStatusResource": { + "description": "Ticket status resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketStatusEnvelope" + } + } + } + }, + "TicketTypeResource": { + "description": "Ticket type resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketTypeEnvelope" + } + } + } + }, + "TicketPriorityResource": { + "description": "Ticket priority resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketPriorityEnvelope" + } + } + } + }, + "TicketFieldDefinitionResource": { + "description": "Ticket field definition resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketFieldDefinitionEnvelope" + } + } + } + }, + "TicketLinkTypeResource": { + "description": "Ticket link type resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketLinkTypeEnvelope" + } + } + } + }, + "RepositoryConnectionResource": { + "description": "Repository connection resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryConnectionEnvelope" + } + } + } + }, + "UserGitConnectionResource": { + "description": "User Git connection resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserGitConnectionEnvelope" + } + } + } + }, + "UserGitConnectionCollection": { + "description": "User Git connection collection.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserGitConnectionCollectionEnvelope" + } + } + } + }, + "BoardPayload": { + "description": "Board page-equivalent JSON payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BoardPayloadEnvelope" + } + } + } + }, + "BacklogPayload": { + "description": "Backlog page-equivalent JSON payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BacklogPayloadEnvelope" + } + } + } + }, + "TicketOptionsPayload": { + "description": "Ticket creation option payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketOptionsPayloadEnvelope" + } + } + } + }, + "TicketDetailPayload": { + "description": "Ticket detail page-equivalent JSON payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetailPayloadEnvelope" + } + } + } + }, + "StatusTargetsPayload": { + "description": "Allowed target statuses for a ticket move.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusTargetsPayloadEnvelope" + } + } + } + }, + "BranchValidationPayload": { + "description": "Branch validation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchValidationPayloadEnvelope" + } + } + } + }, + "BranchCreatedPayload": { + "description": "Created branch result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BranchCreatedPayloadEnvelope" + } + } + } + }, + "BoardSettingsPayload": { + "description": "Board settings page-equivalent JSON payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BoardSettingsPayloadEnvelope" + } + } + } + }, + "MetadataSettingsPayload": { + "description": "Metadata settings page-equivalent JSON payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsPayloadEnvelope" + } + } + } + }, + "RepositorySettingsPayload": { + "description": "Repository settings page-equivalent JSON payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySettingsPayloadEnvelope" + } + } + } + }, + "ValidationError": { + "description": "Laravel validation error response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "Unauthenticated": { + "description": "No authenticated user is available.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageError" + } + } + } + }, + "Forbidden": { + "description": "The authenticated user is not authorized for this action.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageError" + } + } + } + }, + "NotFound": { + "description": "The resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageError" + } + } + } + }, + "NoContent": { + "description": "The resource was deleted successfully." + } + }, + "schemas": { + "Meta": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "MessageError": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + }, + "ValidationError": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": ["message", "errors"] + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "key": { + "type": "string" + }, + "show_author": { + "type": "boolean" + }, + "show_story_points": { + "type": "boolean" + }, + "created_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "updated_at": { + "type": ["string", "null"], + "format": "date-time" + } + }, + "required": ["id", "name", "key"] + }, + "Ticket": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "ticket_key": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "acceptance_criteria": { + "type": ["string", "null"] + }, + "story_points": { + "type": ["integer", "null"] + }, + "is_blocked": { + "type": "boolean" + }, + "ticket_status_id": { + "type": "integer" + }, + "ticket_type_id": { + "type": "integer" + }, + "ticket_priority_id": { + "type": "integer" + } + }, + "required": ["id", "project_id", "ticket_key", "title"] + }, + "TicketReference": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "ticket_key": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + } + }, + "BoardColumn": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "position": { + "type": "integer" + } + }, + "required": ["id", "project_id", "name", "position"] + }, + "TicketStatus": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "board_column_id": { + "type": ["integer", "null"] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "position": { + "type": "integer" + } + } + }, + "TicketType": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "is_parent": { + "type": "boolean" + }, + "position": { + "type": "integer" + } + } + }, + "TicketPriority": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "position": { + "type": "integer" + } + } + }, + "TicketFieldDefinition": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "field_type": { + "type": "string", + "enum": ["number", "user"] + }, + "position": { + "type": "integer" + } + } + }, + "TicketLinkType": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "position": { + "type": "integer" + } + } + }, + "TicketLink": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "linked_ticket": { + "$ref": "#/components/schemas/TicketReference" + }, + "source_ticket_id": { + "type": "integer" + }, + "target_ticket_id": { + "type": "integer" + }, + "ticket_link_type_id": { + "type": "integer" + } + } + }, + "RepositoryConnection": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "provider": { + "type": "string" + }, + "base_url": { + "type": "string", + "format": "uri" + }, + "provider_repository_id": { + "type": "string" + }, + "repository_full_name": { + "type": "string" + }, + "repository_url": { + "type": "string", + "format": "uri" + }, + "has_personal_access": { + "type": "boolean" + } + } + }, + "UserGitConnection": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "provider": { + "type": "string" + }, + "provider_label": { + "type": "string" + }, + "base_url": { + "type": "string", + "format": "uri" + }, + "provider_user_id": { + "type": "string" + }, + "account_login": { + "type": "string" + }, + "account_name": { + "type": ["string", "null"] + } + } + }, + "ProjectEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Project" + } + } + } + ] + }, + "ProjectCollectionEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + ] + }, + "TicketEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/Ticket" + } + } + } + ] + }, + "TicketCollectionEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ticket" + } + } + } + } + ] + }, + "TicketLinkEnvelope": { + "$ref": "#/components/schemas/GenericResourceEnvelope" + }, + "BoardColumnEnvelope": { + "$ref": "#/components/schemas/GenericResourceEnvelope" + }, + "TicketStatusEnvelope": { + "$ref": "#/components/schemas/GenericResourceEnvelope" + }, + "TicketTypeEnvelope": { + "$ref": "#/components/schemas/GenericResourceEnvelope" + }, + "TicketPriorityEnvelope": { + "$ref": "#/components/schemas/GenericResourceEnvelope" + }, + "TicketFieldDefinitionEnvelope": { + "$ref": "#/components/schemas/GenericResourceEnvelope" + }, + "TicketLinkTypeEnvelope": { + "$ref": "#/components/schemas/GenericResourceEnvelope" + }, + "RepositoryConnectionEnvelope": { + "$ref": "#/components/schemas/GenericResourceEnvelope" + }, + "UserGitConnectionEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UserGitConnection" + } + } + } + ] + }, + "UserGitConnectionCollectionEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserGitConnection" + } + } + } + } + ] + }, + "GenericResourceEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + }, + "Envelope": { + "type": "object", + "properties": { + "data": {}, + "meta": { + "$ref": "#/components/schemas/Meta" + } + }, + "required": ["data"] + }, + "BoardPayloadEnvelope": { + "$ref": "#/components/schemas/GenericPayloadEnvelope" + }, + "BacklogPayloadEnvelope": { + "$ref": "#/components/schemas/GenericPayloadEnvelope" + }, + "TicketOptionsPayloadEnvelope": { + "$ref": "#/components/schemas/GenericPayloadEnvelope" + }, + "TicketDetailPayloadEnvelope": { + "$ref": "#/components/schemas/GenericPayloadEnvelope" + }, + "BoardSettingsPayloadEnvelope": { + "$ref": "#/components/schemas/GenericPayloadEnvelope" + }, + "MetadataSettingsPayloadEnvelope": { + "$ref": "#/components/schemas/GenericPayloadEnvelope" + }, + "RepositorySettingsPayloadEnvelope": { + "$ref": "#/components/schemas/GenericPayloadEnvelope" + }, + "StatusTargetsPayloadEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "current_status": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketStatus" + }, + { + "type": "null" + } + ] + }, + "target_statuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TicketStatus" + } + } + } + } + } + } + ] + }, + "BranchValidationPayloadEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/BranchValidationResult" + } + } + } + ] + }, + "BranchCreatedPayloadEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/BranchCreatedResult" + } + } + } + ] + }, + "GenericPayloadEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + }, + "BranchValidationResult": { + "type": "object", + "properties": { + "is_valid": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": ["is_valid", "message"] + }, + "BranchCreatedResult": { + "type": "object", + "properties": { + "branch_name": { + "type": "string" + }, + "repository_full_name": { + "type": "string" + } + }, + "required": ["branch_name", "repository_full_name"] + }, + "ProjectStoreRequest": { + "type": "object", + "properties": { + "key": { + "type": "string", + "maxLength": 5, + "pattern": "^[A-Z0-9]+$" + }, + "name": { + "type": "string", + "maxLength": 255 + } + }, + "required": ["key", "name"] + }, + "DeleteProjectRequest": { + "type": "object", + "properties": { + "confirmation_key": { + "type": "string" + } + }, + "required": ["confirmation_key"] + }, + "TicketStoreRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 255 + }, + "description": { + "type": ["string", "null"] + }, + "acceptance_criteria": { + "type": ["string", "null"] + }, + "assignee_user_id": { + "type": ["integer", "null"] + }, + "story_points": { + "type": ["integer", "null"], + "minimum": 0 + }, + "ticket_priority_id": { + "type": "integer" + }, + "ticket_status_id": { + "type": "integer" + }, + "ticket_type_id": { + "type": "integer" + } + }, + "required": ["title", "ticket_priority_id", "ticket_status_id", "ticket_type_id"] + }, + "TicketUpdateRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/TicketStoreRequest" + }, + { + "type": "object", + "properties": { + "author_user_id": { + "type": ["integer", "null"] + }, + "blocked_comment": { + "type": ["string", "null"] + }, + "is_blocked": { + "type": "boolean" + }, + "parent_ticket_id": { + "type": ["integer", "null"] + }, + "field_values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ticket_field_definition_id": { + "type": "integer" + }, + "value_number": { + "type": ["number", "string", "null"] + }, + "value_user_id": { + "type": ["integer", "string", "null"] + } + }, + "required": ["ticket_field_definition_id"] + } + } + }, + "required": ["is_blocked"] + } + ] + }, + "TicketMoveRequest": { + "type": "object", + "properties": { + "ticket_status_id": { + "type": "integer" + } + }, + "required": ["ticket_status_id"] + }, + "BranchRequest": { + "type": "object", + "properties": { + "branch_name": { + "type": "string", + "maxLength": 255 + }, + "repository_connection_id": { + "type": "integer" + } + }, + "required": ["branch_name", "repository_connection_id"] + }, + "TicketLinkRequest": { + "type": "object", + "properties": { + "linked_ticket_id": { + "type": "integer" + }, + "ticket_link_type_id": { + "type": "integer" + } + }, + "required": ["linked_ticket_id", "ticket_link_type_id"] + }, + "BoardColumnStoreRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "position": { + "type": ["integer", "null"], + "minimum": 1 + } + }, + "required": ["name"] + }, + "BoardColumnUpdateRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/BoardColumnStoreRequest" + }, + { + "type": "object", + "required": ["position"] + } + ] + }, + "TicketStatusStoreRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "board_column_id": { + "type": ["integer", "null"] + } + }, + "required": ["name"] + }, + "TicketStatusUpdateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + } + }, + "required": ["name"] + }, + "TicketStatusTransitionsRequest": { + "type": "object", + "properties": { + "allowed_ticket_status_ids": { + "type": ["array", "null"], + "items": { + "type": "integer" + } + } + } + }, + "MetadataSettingsRequest": { + "type": "object", + "properties": { + "show_author": { + "type": ["boolean", "null"] + }, + "show_story_points": { + "type": ["boolean", "null"] + } + } + }, + "TicketTypeStoreRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "position": { + "type": ["integer", "null"], + "minimum": 1 + }, + "is_parent": { + "type": ["boolean", "null"] + } + }, + "required": ["name"] + }, + "TicketTypeUpdateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "position": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["name", "position"] + }, + "DeleteTicketTypeRequest": { + "type": "object", + "properties": { + "replacement_ticket_type_id": { + "type": ["integer", "null"] + } + } + }, + "NamedSettingStoreRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "position": { + "type": ["integer", "null"], + "minimum": 1 + } + }, + "required": ["name"] + }, + "NamedSettingUpdateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "position": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["name", "position"] + }, + "TicketFieldDefinitionStoreRequest": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 255 + }, + "field_type": { + "type": "string", + "enum": ["number", "user"] + }, + "position": { + "type": ["integer", "null"], + "minimum": 1 + } + }, + "required": ["label", "field_type"] + }, + "TicketFieldDefinitionUpdateRequest": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 255 + }, + "field_type": { + "type": "string", + "enum": ["number", "user"] + }, + "position": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["label", "field_type", "position"] + }, + "RepositorySelectionRequest": { + "type": "object", + "properties": { + "provider_repository_id": { + "type": "string", + "maxLength": 255 + }, + "user_git_connection_id": { + "type": "integer" + } + }, + "required": ["provider_repository_id", "user_git_connection_id"] + }, + "UserGitConnectionRequest": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["gitea"] + }, + "base_url": { + "type": "string", + "format": "uri", + "maxLength": 255 + }, + "api_token": { + "type": "string", + "maxLength": 2048, + "writeOnly": true + } + }, + "required": ["provider", "base_url", "api_token"] + } + } + } +} diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..630ab56 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,5 @@ +name('board'); + +Route::prefix('board') + ->name('board.') + ->controller(ProjectBoardSettingsController::class) + ->group(function (): void { + require __DIR__.'/settings/board.php'; + }); + +Route::get('metadata', [ProjectTicketMetadataSettingsController::class, 'show'])->name('metadata'); +Route::patch('metadata', [ProjectTicketMetadataSettingsController::class, 'update'])->name('metadata.update'); + +Route::prefix('metadata') + ->name('metadata.') + ->controller(ProjectTicketMetadataSettingsController::class) + ->group(function (): void { + require __DIR__.'/settings/metadata.php'; + }); + +Route::get('repositories', [ProjectRepositorySettingsController::class, 'show'])->name('repositories'); + +Route::prefix('repositories') + ->name('repositories.') + ->controller(ProjectRepositorySettingsController::class) + ->group(function (): void { + require __DIR__.'/settings/repositories.php'; + }); diff --git a/routes/api/project/settings/board.php b/routes/api/project/settings/board.php new file mode 100644 index 0000000..4da2538 --- /dev/null +++ b/routes/api/project/settings/board.php @@ -0,0 +1,16 @@ +name('columns.')->group(function (): void { + Route::post('/', 'storeColumn')->name('store'); + Route::patch('{boardColumn}', 'updateColumn')->name('update'); + Route::delete('{boardColumn}', 'destroyColumn')->name('destroy'); +}); + +Route::prefix('statuses')->name('statuses.')->group(function (): void { + Route::post('/', 'storeStatus')->name('store'); + Route::patch('{ticketStatus}', 'updateStatus')->name('update'); + Route::delete('{ticketStatus}', 'destroyStatus')->name('destroy'); + Route::patch('{ticketStatus}/transitions', 'updateStatusTransitions')->name('transitions.update'); +}); diff --git a/routes/api/project/settings/metadata.php b/routes/api/project/settings/metadata.php new file mode 100644 index 0000000..28023f1 --- /dev/null +++ b/routes/api/project/settings/metadata.php @@ -0,0 +1,27 @@ +name('types.')->group(function (): void { + Route::post('/', 'storeType')->name('store'); + Route::patch('{ticketType}', 'updateType')->name('update'); + Route::delete('{ticketType}', 'destroyType')->name('destroy'); +}); + +Route::prefix('priorities')->name('priorities.')->group(function (): void { + Route::post('/', 'storePriority')->name('store'); + Route::patch('{ticketPriority}', 'updatePriority')->name('update'); + Route::delete('{ticketPriority}', 'destroyPriority')->name('destroy'); +}); + +Route::prefix('fields')->name('fields.')->group(function (): void { + Route::post('/', 'storeFieldDefinition')->name('store'); + Route::patch('{ticketFieldDefinition}', 'updateFieldDefinition')->name('update'); + Route::delete('{ticketFieldDefinition}', 'destroyFieldDefinition')->name('destroy'); +}); + +Route::prefix('link-types')->name('link-types.')->group(function (): void { + Route::post('/', 'storeLinkType')->name('store'); + Route::patch('{ticketLinkType}', 'updateLinkType')->name('update'); + Route::delete('{ticketLinkType}', 'destroyLinkType')->name('destroy'); +}); diff --git a/routes/api/project/settings/repositories.php b/routes/api/project/settings/repositories.php new file mode 100644 index 0000000..a2974b3 --- /dev/null +++ b/routes/api/project/settings/repositories.php @@ -0,0 +1,7 @@ +name('store'); +Route::patch('{repositoryConnection}', 'update')->name('update'); +Route::delete('{repositoryConnection}', 'destroy')->name('destroy'); diff --git a/routes/api/project/tickets.php b/routes/api/project/tickets.php new file mode 100644 index 0000000..454032e --- /dev/null +++ b/routes/api/project/tickets.php @@ -0,0 +1,31 @@ +group(function (): void { + Route::get('/', [ProjectTicketController::class, 'show'])->name('show'); + Route::patch('/', [ProjectTicketController::class, 'update'])->name('update'); + Route::get('status-targets', [ProjectTicketController::class, 'statusTargets'])->name('status-targets'); + Route::patch('status', [ProjectTicketController::class, 'move'])->name('status.update'); + + Route::prefix('branches') + ->name('branches.') + ->controller(ProjectTicketBranchController::class) + ->group(function (): void { + Route::get('validate', 'validate')->name('validate'); + Route::post('/', 'store')->name('store'); + }); + + Route::prefix('links') + ->name('links.') + ->controller(ProjectTicketLinkController::class) + ->group(function (): void { + Route::post('/', 'store')->name('store'); + Route::delete('{ticketLink}', 'destroy') + ->withoutScopedBindings() + ->name('destroy'); + }); +}); diff --git a/routes/api/projects.php b/routes/api/projects.php new file mode 100644 index 0000000..7e19ff0 --- /dev/null +++ b/routes/api/projects.php @@ -0,0 +1,38 @@ +name('api.projects.') + ->group(function (): void { + Route::get('/', [ProjectController::class, 'index'])->name('index'); + Route::post('/', [ProjectController::class, 'store'])->name('store'); + + Route::prefix('{project}') + ->group(function (): void { + Route::get('/', [ProjectController::class, 'show'])->name('show'); + Route::delete('/', [ProjectController::class, 'destroy'])->name('destroy'); + Route::get('board', [ProjectBoardController::class, 'board'])->name('board'); + Route::get('backlog', [ProjectBoardController::class, 'backlog'])->name('backlog'); + Route::get('ticket-options', [ProjectTicketController::class, 'options'])->name('ticket-options'); + Route::get('tickets', [ProjectTicketController::class, 'index'])->name('tickets.index'); + Route::post('tickets', [ProjectTicketController::class, 'store'])->name('tickets.store'); + + Route::scopeBindings()->group(function (): void { + Route::prefix('tickets') + ->name('tickets.') + ->group(function (): void { + require __DIR__.'/project/tickets.php'; + }); + + Route::prefix('settings') + ->name('settings.') + ->group(function (): void { + require __DIR__.'/project/settings.php'; + }); + }); + }); + }); diff --git a/routes/api/settings.php b/routes/api/settings.php new file mode 100644 index 0000000..2ccb1c2 --- /dev/null +++ b/routes/api/settings.php @@ -0,0 +1,14 @@ +name('api.git-connections.') + ->controller(GitConnectionController::class) + ->group(function (): void { + Route::get('/', 'index')->name('index'); + Route::post('/', 'store')->name('store'); + Route::patch('{gitConnection}', 'update')->name('update'); + Route::delete('{gitConnection}', 'destroy')->name('destroy'); + }); diff --git a/tests/Feature/Api/OpenApiDocumentationTest.php b/tests/Feature/Api/OpenApiDocumentationTest.php new file mode 100644 index 0000000..e6bd11c --- /dev/null +++ b/tests/Feature/Api/OpenApiDocumentationTest.php @@ -0,0 +1,81 @@ +assertFileExists($documentPath); + + $document = json_decode((string) file_get_contents($documentPath), true); + + $this->assertIsArray($document); + $this->assertSame(JSON_ERROR_NONE, json_last_error()); + $this->assertSame('3.1.0', $document['openapi']); + $this->assertSame([], $document['security']); + + $expectedOperations = [ + ['get', '/projects'], + ['post', '/projects'], + ['get', '/projects/{project}'], + ['delete', '/projects/{project}'], + ['get', '/projects/{project}/board'], + ['get', '/projects/{project}/backlog'], + ['get', '/projects/{project}/ticket-options'], + ['get', '/projects/{project}/tickets'], + ['post', '/projects/{project}/tickets'], + ['get', '/projects/{project}/tickets/{ticket}'], + ['patch', '/projects/{project}/tickets/{ticket}'], + ['get', '/projects/{project}/tickets/{ticket}/status-targets'], + ['patch', '/projects/{project}/tickets/{ticket}/status'], + ['get', '/projects/{project}/tickets/{ticket}/branches/validate'], + ['post', '/projects/{project}/tickets/{ticket}/branches'], + ['post', '/projects/{project}/tickets/{ticket}/links'], + ['delete', '/projects/{project}/tickets/{ticket}/links/{ticketLink}'], + ['get', '/projects/{project}/settings/board'], + ['post', '/projects/{project}/settings/board/columns'], + ['patch', '/projects/{project}/settings/board/columns/{boardColumn}'], + ['delete', '/projects/{project}/settings/board/columns/{boardColumn}'], + ['post', '/projects/{project}/settings/board/statuses'], + ['patch', '/projects/{project}/settings/board/statuses/{ticketStatus}'], + ['delete', '/projects/{project}/settings/board/statuses/{ticketStatus}'], + ['patch', '/projects/{project}/settings/board/statuses/{ticketStatus}/transitions'], + ['get', '/projects/{project}/settings/metadata'], + ['patch', '/projects/{project}/settings/metadata'], + ['post', '/projects/{project}/settings/metadata/types'], + ['patch', '/projects/{project}/settings/metadata/types/{ticketType}'], + ['delete', '/projects/{project}/settings/metadata/types/{ticketType}'], + ['post', '/projects/{project}/settings/metadata/priorities'], + ['patch', '/projects/{project}/settings/metadata/priorities/{ticketPriority}'], + ['delete', '/projects/{project}/settings/metadata/priorities/{ticketPriority}'], + ['post', '/projects/{project}/settings/metadata/fields'], + ['patch', '/projects/{project}/settings/metadata/fields/{ticketFieldDefinition}'], + ['delete', '/projects/{project}/settings/metadata/fields/{ticketFieldDefinition}'], + ['post', '/projects/{project}/settings/metadata/link-types'], + ['patch', '/projects/{project}/settings/metadata/link-types/{ticketLinkType}'], + ['delete', '/projects/{project}/settings/metadata/link-types/{ticketLinkType}'], + ['get', '/projects/{project}/settings/repositories'], + ['post', '/projects/{project}/settings/repositories'], + ['patch', '/projects/{project}/settings/repositories/{repositoryConnection}'], + ['delete', '/projects/{project}/settings/repositories/{repositoryConnection}'], + ['get', '/git-connections'], + ['post', '/git-connections'], + ['patch', '/git-connections/{gitConnection}'], + ['delete', '/git-connections/{gitConnection}'], + ]; + + foreach ($expectedOperations as [$method, $path]) { + $this->assertArrayHasKey($path, $document['paths']); + $this->assertArrayHasKey($method, $document['paths'][$path]); + } + + $this->assertArrayHasKey('ValidationError', $document['components']['schemas']); + $this->assertArrayHasKey('TicketUpdateRequest', $document['components']['schemas']); + $this->assertArrayHasKey('UserGitConnectionRequest', $document['components']['schemas']); + } +} diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php new file mode 100644 index 0000000..9e263e2 --- /dev/null +++ b/tests/Feature/Api/ProjectApiTest.php @@ -0,0 +1,87 @@ +create(); + $visibleProject = Project::factory()->create([ + 'name' => 'Visible project', + ]); + Project::factory()->create([ + 'name' => 'Hidden project', + ]); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + + $this->actingAs($user) + ->getJson(route('api.projects.index')) + ->assertOk() + ->assertJsonPath('data.0.name', 'Visible project') + ->assertJsonCount(1, 'data'); + } + + public function test_users_with_project_create_permission_can_create_projects(): void + { + $user = User::factory()->create(); + $this->syncGlobalPermissions($user, ['create']); + + $this->actingAs($user) + ->postJson(route('api.projects.store'), [ + 'key' => 'api', + 'name' => 'API Project', + ]) + ->assertCreated() + ->assertJsonPath('data.key', 'API') + ->assertJsonPath('data.name', 'API Project') + ->assertJsonPath('meta.message', 'API Project was created.'); + + $project = Project::query()->where('key', 'API')->firstOrFail(); + $this->assertSame(2, $project->ticketTypes()->count()); + $this->assertSame(1, $project->ticketStatuses()->count()); + } + + public function test_project_show_and_delete_enforce_permissions(): void + { + $user = User::factory()->create(); + $admin = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $this->actingAs($user) + ->getJson(route('api.projects.show', $project)) + ->assertForbidden(); + + $this->syncProjectPermissions($project, $user, ['view']); + + $this->actingAs($user) + ->getJson(route('api.projects.show', $project)) + ->assertOk() + ->assertJsonPath('data.key', 'CORE'); + + $this->syncGlobalPermissions($admin, ['admin']); + + $this->actingAs($admin) + ->deleteJson(route('api.projects.destroy', $project), [ + 'confirmation_key' => 'CORE', + ]) + ->assertNoContent(); + + $this->assertDatabaseMissing('projects', [ + 'id' => $project->id, + ]); + } +} diff --git a/tests/Feature/Api/SettingsApiTest.php b/tests/Feature/Api/SettingsApiTest.php new file mode 100644 index 0000000..b5ec766 --- /dev/null +++ b/tests/Feature/Api/SettingsApiTest.php @@ -0,0 +1,231 @@ +projectContext(); + + $this->actingAs($user) + ->getJson(route('api.projects.settings.board', $project)) + ->assertOk() + ->assertJsonPath('data.backlogTicketStatuses.0.name', 'Backlog'); + + $columnId = $this->actingAs($user) + ->postJson(route('api.projects.settings.board.columns.store', $project), [ + 'name' => 'In Review', + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'In Review') + ->json('data.id'); + + $this->actingAs($user) + ->patchJson(route('api.projects.settings.board.columns.update', [$project, $columnId]), [ + 'name' => 'Review', + 'position' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.name', 'Review'); + + $statusId = $this->actingAs($user) + ->postJson(route('api.projects.settings.board.statuses.store', $project), [ + 'board_column_id' => $columnId, + 'name' => 'Ready', + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'Ready') + ->json('data.id'); + $replacementStatusId = $this->actingAs($user) + ->postJson(route('api.projects.settings.board.statuses.store', $project), [ + 'board_column_id' => $columnId, + 'name' => 'QA', + ]) + ->assertCreated() + ->json('data.id'); + + $backlogStatus = $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail(); + + $this->actingAs($user) + ->patchJson(route('api.projects.settings.board.statuses.transitions.update', [$project, $backlogStatus]), [ + 'allowed_ticket_status_ids' => [$statusId], + ]) + ->assertOk() + ->assertJsonPath('data.allowed_target_statuses.0.id', $statusId); + + $this->actingAs($user) + ->deleteJson(route('api.projects.settings.board.statuses.destroy', [$project, $statusId])) + ->assertNoContent(); + + $this->assertDatabaseHas('ticket_statuses', [ + 'id' => $replacementStatusId, + ]); + + $this->actingAs($user) + ->deleteJson(route('api.projects.settings.board.columns.destroy', [$project, $columnId])) + ->assertNoContent(); + } + + public function test_metadata_settings_can_be_read_and_mutated(): void + { + [$user, $project] = $this->projectContext(); + + $this->actingAs($user) + ->getJson(route('api.projects.settings.metadata', $project)) + ->assertOk() + ->assertJsonPath('data.ticketTypes.0.name', 'Task'); + + $this->actingAs($user) + ->patchJson(route('api.projects.settings.metadata.update', $project), [ + 'show_author' => false, + 'show_story_points' => false, + ]) + ->assertOk() + ->assertJsonPath('data.show_author', false); + + $typeId = $this->actingAs($user) + ->postJson(route('api.projects.settings.metadata.types.store', $project), [ + 'is_parent' => true, + 'name' => 'Epic', + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'Epic') + ->json('data.id'); + + $this->actingAs($user) + ->patchJson(route('api.projects.settings.metadata.types.update', [$project, $typeId]), [ + 'name' => 'Initiative', + 'position' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.name', 'Initiative'); + + $priorityId = $this->actingAs($user) + ->postJson(route('api.projects.settings.metadata.priorities.store', $project), [ + 'name' => 'Urgent', + ]) + ->assertCreated() + ->json('data.id'); + + $fieldId = $this->actingAs($user) + ->postJson(route('api.projects.settings.metadata.fields.store', $project), [ + 'field_type' => 'number', + 'label' => 'Risk', + ]) + ->assertCreated() + ->json('data.id'); + + $linkTypeId = $this->actingAs($user) + ->postJson(route('api.projects.settings.metadata.link-types.store', $project), [ + 'name' => 'Relates to', + ]) + ->assertCreated() + ->json('data.id'); + + $this->actingAs($user) + ->deleteJson(route('api.projects.settings.metadata.fields.destroy', [$project, $fieldId])) + ->assertNoContent(); + + $this->actingAs($user) + ->deleteJson(route('api.projects.settings.metadata.link-types.destroy', [$project, $linkTypeId])) + ->assertNoContent(); + + $this->actingAs($user) + ->deleteJson(route('api.projects.settings.metadata.priorities.destroy', [$project, $priorityId])) + ->assertNoContent(); + + $this->actingAs($user) + ->deleteJson(route('api.projects.settings.metadata.types.destroy', [$project, $typeId])) + ->assertNoContent(); + } + + public function test_project_repositories_and_user_git_connections_are_available(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://git.example.test/api/v1/user' => Http::response([ + 'id' => 101, + 'login' => 'api-user', + 'full_name' => 'API User', + ]), + 'https://git.example.test/api/v1/user/repos*' => Http::response([ + [ + 'id' => 501, + 'name' => 'kanbangit', + 'full_name' => 'core-team/kanbangit', + 'html_url' => 'https://git.example.test/core-team/kanbangit', + 'owner' => [ + 'login' => 'core-team', + ], + 'private' => true, + ], + ]), + ]); + + [$user, $project] = $this->projectContext(); + + $gitConnectionId = $this->actingAs($user) + ->postJson(route('api.git-connections.store'), [ + 'api_token' => 'secret-token', + 'base_url' => 'https://git.example.test/api/v1', + 'provider' => 'gitea', + ]) + ->assertCreated() + ->assertJsonPath('data.account_login', 'api-user') + ->json('data.id'); + + $this->actingAs($user) + ->getJson(route('api.git-connections.index')) + ->assertOk() + ->assertJsonPath('data.0.id', $gitConnectionId); + + $repositoryConnectionId = $this->actingAs($user) + ->postJson(route('api.projects.settings.repositories.store', $project), [ + 'provider_repository_id' => '501', + 'user_git_connection_id' => $gitConnectionId, + ]) + ->assertCreated() + ->assertJsonPath('data.repository_full_name', 'core-team/kanbangit') + ->json('data.id'); + + $this->actingAs($user) + ->getJson(route('api.projects.settings.repositories', $project)) + ->assertOk() + ->assertJsonPath('data.repositoryConnections.0.id', $repositoryConnectionId) + ->assertJsonPath('data.availableRepositorySources.0.repositories.0.full_name', 'core-team/kanbangit'); + + $this->actingAs($user) + ->deleteJson(route('api.projects.settings.repositories.destroy', [$project, $repositoryConnectionId])) + ->assertNoContent(); + + $this->actingAs($user) + ->deleteJson(route('api.git-connections.destroy', $gitConnectionId)) + ->assertNoContent(); + } + + /** + * @return array{0: User, 1: Project} + */ + private function projectContext(): array + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + $this->syncProjectPermissions($project, $user, ['edit', 'view']); + + return [$user, $project]; + } +} diff --git a/tests/Feature/Api/TicketApiTest.php b/tests/Feature/Api/TicketApiTest.php new file mode 100644 index 0000000..3a4f044 --- /dev/null +++ b/tests/Feature/Api/TicketApiTest.php @@ -0,0 +1,264 @@ +projectContext(); + + $priority = $project->ticketPriorities()->firstOrFail(); + $status = $project->ticketStatuses()->firstOrFail(); + $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); + + $this->actingAs($user) + ->getJson(route('api.projects.ticket-options', [ + 'project' => $project, + 'source' => 'backlog', + ])) + ->assertOk() + ->assertJsonPath('data.defaultTicketStatusId', $status->id) + ->assertJsonPath('data.ticketTypes.0.id', $type->id); + + $createResponse = $this->actingAs($user) + ->postJson(route('api.projects.tickets.store', $project), [ + 'acceptance_criteria' => 'Can sign in', + 'description' => 'Build the API-created ticket', + 'story_points' => 3, + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $status->id, + 'ticket_type_id' => $type->id, + 'title' => 'API ticket', + ]) + ->assertCreated() + ->assertJsonPath('data.ticket_key', 'CORE-1') + ->assertJsonPath('meta.message', 'CORE-1 was created.'); + + $ticket = Ticket::query()->findOrFail($createResponse->json('data.id')); + + $this->actingAs($user) + ->getJson(route('api.projects.tickets.index', $project)) + ->assertOk() + ->assertJsonPath('data.0.ticket_key', 'CORE-1'); + + $this->actingAs($user) + ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ + 'acceptance_criteria' => 'Can sign in from the API', + 'blocked_comment' => null, + 'description' => 'Updated through the API', + 'is_blocked' => false, + 'parent_ticket_id' => null, + 'story_points' => 5, + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $type->id, + 'title' => 'Updated API ticket', + ]) + ->assertOk() + ->assertJsonPath('data.title', 'Updated API ticket') + ->assertJsonPath('meta.message', 'CORE-1 was updated.'); + + $this->actingAs($user) + ->getJson(route('api.projects.tickets.show', [$project, $ticket])) + ->assertOk() + ->assertJsonPath('data.ticket.ticket_key', 'CORE-1') + ->assertJsonPath('data.ticket.story_points', 5); + } + + public function test_tickets_can_return_status_targets_and_move_statuses(): void + { + [$user, $project] = $this->projectContext(); + + $backlogStatus = $project->ticketStatuses()->firstOrFail(); + $boardColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Doing', + 'position' => 1, + ]); + $doingStatus = TicketStatus::factory() + ->for($project) + ->for($boardColumn) + ->create([ + 'name' => 'Doing', + 'position' => 1, + ]); + $backlogStatus->allowedTargetStatuses()->sync([$doingStatus->id]); + + $ticket = $this->ticket($project, $backlogStatus); + + $this->actingAs($user) + ->getJson(route('api.projects.tickets.status-targets', [$project, $ticket])) + ->assertOk() + ->assertJsonPath('data.current_status.id', $backlogStatus->id) + ->assertJsonPath('data.target_statuses.0.id', $doingStatus->id); + + $this->actingAs($user) + ->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [ + 'ticket_status_id' => $doingStatus->id, + ]) + ->assertOk() + ->assertJsonPath('data.ticket_status_id', $doingStatus->id) + ->assertJsonPath('meta.message', 'CORE-1 moved to Doing.'); + + $this->assertDatabaseHas('ticket_worklog_entries', [ + 'ticket_id' => $ticket->id, + 'actor_id' => $user->id, + 'entry_type' => 'status_changed', + ]); + } + + public function test_ticket_links_and_branch_endpoints_are_available(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::sequence() + ->push([ + ['name' => 'main'], + ], 200) + ->push([ + ['name' => 'main'], + ], 200) + ->push([ + 'name' => 'core-1-api-ticket', + ], 201), + 'https://git.example.test/api/v1/repos/core-team/kanbangit' => Http::response([ + 'id' => 501, + 'name' => 'kanbangit', + 'full_name' => 'core-team/kanbangit', + 'html_url' => 'https://git.example.test/core-team/kanbangit', + 'default_branch' => 'main', + 'owner' => [ + 'login' => 'core-team', + ], + 'private' => false, + ]), + ]); + + [$user, $project] = $this->projectContext(); + $ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail()); + $linkedTicket = $this->ticket($project, $project->ticketStatuses()->firstOrFail(), 2); + $linkType = TicketLinkType::factory()->for($project)->create([ + 'label' => 'Relates to', + 'position' => 1, + ]); + + $this->actingAs($user) + ->postJson(route('api.projects.tickets.links.store', [$project, $ticket]), [ + 'linked_ticket_id' => $linkedTicket->id, + 'ticket_link_type_id' => $linkType->id, + ]) + ->assertCreated() + ->assertJsonPath('data.ticket_link_type.label', 'Relates to') + ->assertJsonPath('meta.message', 'Relates to link was saved.'); + + $repositoryConnection = RepositoryConnection::factory()->for($project)->create([ + 'base_url' => 'https://git.example.test', + 'owner_name' => 'core-team', + 'provider' => GitProviderKey::Gitea, + 'provider_repository_id' => '501', + 'repository_full_name' => 'core-team/kanbangit', + 'repository_name' => 'kanbangit', + 'repository_url' => 'https://git.example.test/core-team/kanbangit', + ]); + UserGitConnection::factory()->for($user)->create([ + 'api_token' => 'branch-token', + 'base_url' => 'https://git.example.test', + 'provider' => GitProviderKey::Gitea, + ]); + + $this->actingAs($user) + ->getJson(route('api.projects.tickets.branches.validate', [ + 'project' => $project, + 'ticket' => $ticket, + 'branch_name' => 'core-1-api-ticket', + 'repository_connection_id' => $repositoryConnection->id, + ])) + ->assertOk() + ->assertJsonPath('data.is_valid', true); + + $this->actingAs($user) + ->postJson(route('api.projects.tickets.branches.store', [$project, $ticket]), [ + 'branch_name' => 'core-1-api-ticket', + 'repository_connection_id' => $repositoryConnection->id, + ]) + ->assertCreated() + ->assertJsonPath('data.branch_name', 'core-1-api-ticket') + ->assertJsonPath('meta.message', 'core-1-api-ticket was created in core-team/kanbangit.'); + } + + public function test_board_and_backlog_payloads_are_returned(): void + { + [$user, $project] = $this->projectContext(); + $backlogStatus = $project->ticketStatuses()->firstOrFail(); + $ticket = $this->ticket($project, $backlogStatus); + + $boardColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Todo', + 'position' => 1, + ]); + TicketStatus::factory() + ->for($project) + ->for($boardColumn) + ->create([ + 'name' => 'Todo', + 'position' => 1, + ]); + + $this->actingAs($user) + ->getJson(route('api.projects.backlog', $project)) + ->assertOk() + ->assertJsonPath('data.tickets.0.id', $ticket->id); + + $this->actingAs($user) + ->getJson(route('api.projects.board', $project)) + ->assertOk() + ->assertJsonPath('data.boardColumns.0.name', 'Todo'); + } + + /** + * @return array{0: User, 1: Project} + */ + private function projectContext(): array + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + $this->syncProjectPermissions($project, $user, ['edit', 'view']); + + return [$user, $project]; + } + + private function ticket(Project $project, TicketStatus $ticketStatus, int $sequenceNumber = 1): Ticket + { + $priority = $project->ticketPriorities()->firstOrFail(); + $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); + + return Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => $sequenceNumber, + 'ticket_key' => "CORE-{$sequenceNumber}", + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $ticketStatus->id, + 'ticket_type_id' => $type->id, + 'title' => 'API ticket', + ]); + } +} From 740a20899cc03f2db851249e2f9ede0ee1ca6737 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 20 Apr 2026 23:54:37 +0000 Subject: [PATCH 2/6] Scope project user payloads to visible users --- .../Projects/BuildProjectBacklogPayload.php | 15 ++++++++ .../BuildProjectTicketDetailPayload.php | 15 ++++++++ tests/Feature/Api/SettingsApiTest.php | 38 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/app/Actions/Projects/BuildProjectBacklogPayload.php b/app/Actions/Projects/BuildProjectBacklogPayload.php index eb71b01..e7b8622 100644 --- a/app/Actions/Projects/BuildProjectBacklogPayload.php +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -59,9 +59,24 @@ class BuildProjectBacklogPayload )->resolve(), 'users' => UserResource::collection( User::query() + ->whereIn('id', $this->visibleUserIds($project)) ->orderBy('name') ->get(), )->resolve(), ]; } + + /** + * @return array + */ + private function visibleUserIds(Project $project): array + { + return User::query() + ->get() + ->filter(fn (User $candidate): bool => $this->permissionService->allows($candidate, Permission::ProjectRead, $project)) + ->pluck('id') + ->map(fn (mixed $userId): int => (int) $userId) + ->values() + ->all(); + } } diff --git a/app/Actions/Projects/BuildProjectTicketDetailPayload.php b/app/Actions/Projects/BuildProjectTicketDetailPayload.php index 26b4511..56a014b 100644 --- a/app/Actions/Projects/BuildProjectTicketDetailPayload.php +++ b/app/Actions/Projects/BuildProjectTicketDetailPayload.php @@ -116,9 +116,24 @@ class BuildProjectTicketDetailPayload 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), 'users' => UserResource::collection( User::query() + ->whereIn('id', $this->visibleUserIds($project)) ->orderBy('name') ->get(), )->resolve(), ]; } + + /** + * @return array + */ + private function visibleUserIds(Project $project): array + { + return User::query() + ->get() + ->filter(fn (User $candidate): bool => $this->permissionService->allows($candidate, Permission::ProjectRead, $project)) + ->pluck('id') + ->map(fn (mixed $userId): int => (int) $userId) + ->values() + ->all(); + } } diff --git a/tests/Feature/Api/SettingsApiTest.php b/tests/Feature/Api/SettingsApiTest.php index b5ec766..686338d 100644 --- a/tests/Feature/Api/SettingsApiTest.php +++ b/tests/Feature/Api/SettingsApiTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Api; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\Project; +use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -149,6 +150,43 @@ class SettingsApiTest extends TestCase ->assertNoContent(); } + public function test_project_payloads_only_return_users_who_can_view_the_project(): void + { + [$user, $project] = $this->projectContext(); + $viewer = User::factory()->create(['name' => 'Visible User']); + User::factory()->create(['name' => 'Hidden User']); + $teamViewer = User::factory()->create(['name' => 'Team Viewer']); + $team = Team::factory()->create(); + + $this->syncProjectPermissions($project, $viewer, ['view']); + $team->users()->attach($teamViewer); + $this->syncProjectPermissions($project, $team, ['view']); + + $ticket = $project->tickets()->firstOrCreate([ + 'sequence_number' => 1, + ], [ + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, + 'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id, + 'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id, + 'title' => 'Scoped users ticket', + ]); + + $this->actingAs($user) + ->getJson(route('api.projects.backlog', $project)) + ->assertOk() + ->assertJsonPath('data.users.0.name', 'Team Viewer') + ->assertJsonPath('data.users.1.name', 'Visible User') + ->assertJsonCount(2, 'data.users'); + + $this->actingAs($user) + ->getJson(route('api.projects.tickets.show', [$project, $ticket])) + ->assertOk() + ->assertJsonPath('data.users.0.name', 'Team Viewer') + ->assertJsonPath('data.users.1.name', 'Visible User') + ->assertJsonCount(2, 'data.users'); + } + public function test_project_repositories_and_user_git_connections_are_available(): void { Http::preventStrayRequests(); From f412e4ec19b23bcb053c52e77f44fe87e9ada440 Mon Sep 17 00:00:00 2001 From: Koda Date: Tue, 21 Apr 2026 00:08:12 +0000 Subject: [PATCH 3/6] Refactor visible project users into query scope --- .../Projects/BuildProjectBacklogPayload.php | 16 +--------------- .../Projects/BuildProjectTicketDetailPayload.php | 16 +--------------- app/Models/User.php | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/app/Actions/Projects/BuildProjectBacklogPayload.php b/app/Actions/Projects/BuildProjectBacklogPayload.php index e7b8622..bff6315 100644 --- a/app/Actions/Projects/BuildProjectBacklogPayload.php +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -59,24 +59,10 @@ class BuildProjectBacklogPayload )->resolve(), 'users' => UserResource::collection( User::query() - ->whereIn('id', $this->visibleUserIds($project)) + ->visibleToProject($project) ->orderBy('name') ->get(), )->resolve(), ]; } - - /** - * @return array - */ - private function visibleUserIds(Project $project): array - { - return User::query() - ->get() - ->filter(fn (User $candidate): bool => $this->permissionService->allows($candidate, Permission::ProjectRead, $project)) - ->pluck('id') - ->map(fn (mixed $userId): int => (int) $userId) - ->values() - ->all(); - } } diff --git a/app/Actions/Projects/BuildProjectTicketDetailPayload.php b/app/Actions/Projects/BuildProjectTicketDetailPayload.php index 56a014b..25257cb 100644 --- a/app/Actions/Projects/BuildProjectTicketDetailPayload.php +++ b/app/Actions/Projects/BuildProjectTicketDetailPayload.php @@ -116,24 +116,10 @@ class BuildProjectTicketDetailPayload 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), 'users' => UserResource::collection( User::query() - ->whereIn('id', $this->visibleUserIds($project)) + ->visibleToProject($project) ->orderBy('name') ->get(), )->resolve(), ]; } - - /** - * @return array - */ - private function visibleUserIds(Project $project): array - { - return User::query() - ->get() - ->filter(fn (User $candidate): bool => $this->permissionService->allows($candidate, Permission::ProjectRead, $project)) - ->pluck('id') - ->map(fn (mixed $userId): int => (int) $userId) - ->values() - ->all(); - } } diff --git a/app/Models/User.php b/app/Models/User.php index ef51f81..b84a873 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,12 +3,14 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Actions\Permissions\PermissionService; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -69,4 +71,17 @@ class User extends Authenticatable { return $this->hasMany(Ticket::class, 'author_user_id'); } + + public function scopeVisibleToProject(Builder $query, Project $project): Builder + { + $permissionService = app(PermissionService::class); + + return $query->where(function (Builder $builder) use ($permissionService, $project): void { + foreach (User::query()->select('id')->cursor() as $candidate) { + if ($permissionService->allows($candidate, \App\Enums\Permission::ProjectRead, $project)) { + $builder->orWhereKey($candidate->id); + } + } + }); + } } From c9a3a7f58d2b5651c4bc55eaa75e3ff6332aa5c1 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 22 Apr 2026 10:39:59 +0200 Subject: [PATCH 4/6] fixed scope --- app/Models/User.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/Models/User.php b/app/Models/User.php index b84a873..22fdb24 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,13 +4,14 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -76,12 +77,17 @@ class User extends Authenticatable { $permissionService = app(PermissionService::class); - return $query->where(function (Builder $builder) use ($permissionService, $project): void { - foreach (User::query()->select('id')->cursor() as $candidate) { - if ($permissionService->allows($candidate, \App\Enums\Permission::ProjectRead, $project)) { - $builder->orWhereKey($candidate->id); - } - } - }); + $allowedUserIds = User::query() + ->select('id') + ->cursor() + ->filter(fn (User $candidate) => $permissionService->allows( + $candidate, + Permission::ProjectRead, + $project + )) + ->pluck('id') + ->all(); + + return $query->whereKey($allowedUserIds); } } From 3f4c8f49d9147244c6e1659b4680f4007ccd6cad Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 22 Apr 2026 10:57:31 +0200 Subject: [PATCH 5/6] fixed test scope --- tests/Feature/Api/SettingsApiTest.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/Feature/Api/SettingsApiTest.php b/tests/Feature/Api/SettingsApiTest.php index 686338d..5f88734 100644 --- a/tests/Feature/Api/SettingsApiTest.php +++ b/tests/Feature/Api/SettingsApiTest.php @@ -175,16 +175,18 @@ class SettingsApiTest extends TestCase $this->actingAs($user) ->getJson(route('api.projects.backlog', $project)) ->assertOk() - ->assertJsonPath('data.users.0.name', 'Team Viewer') - ->assertJsonPath('data.users.1.name', 'Visible User') - ->assertJsonCount(2, 'data.users'); + ->assertJsonPath('data.users.0.name', $user->name) + ->assertJsonPath('data.users.1.name', 'Team Viewer') + ->assertJsonPath('data.users.2.name', 'Visible User') + ->assertJsonCount(3, 'data.users'); $this->actingAs($user) ->getJson(route('api.projects.tickets.show', [$project, $ticket])) ->assertOk() - ->assertJsonPath('data.users.0.name', 'Team Viewer') - ->assertJsonPath('data.users.1.name', 'Visible User') - ->assertJsonCount(2, 'data.users'); + ->assertJsonPath('data.users.0.name', $user->name) + ->assertJsonPath('data.users.1.name', 'Team Viewer') + ->assertJsonPath('data.users.2.name', 'Visible User') + ->assertJsonCount(3, 'data.users'); } public function test_project_repositories_and_user_git_connections_are_available(): void From 3d3b03ee8294da2945a46d59609e26c8dc278093 Mon Sep 17 00:00:00 2001 From: Koda Date: Thu, 23 Apr 2026 20:56:39 +0000 Subject: [PATCH 6/6] Fix PR52 API author visibility and ticket-option user scoping --- .../BuildProjectTicketOptionsPayload.php | 1 + .../Api/ProjectTicketController.php | 21 +++++- tests/Feature/Api/TicketApiTest.php | 71 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php index a22aea1..7c6a307 100644 --- a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php +++ b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php @@ -38,6 +38,7 @@ class BuildProjectTicketOptionsPayload 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), 'users' => UserResource::collection( User::query() + ->visibleToProject($project) ->orderBy('name') ->get(), )->resolve(), diff --git a/app/Http/Controllers/Api/ProjectTicketController.php b/app/Http/Controllers/Api/ProjectTicketController.php index 02409e5..337fda9 100644 --- a/app/Http/Controllers/Api/ProjectTicketController.php +++ b/app/Http/Controllers/Api/ProjectTicketController.php @@ -123,7 +123,8 @@ class ProjectTicketController extends ApiController ? "{$ticket->ticket_key} was updated." : 'No ticket changes were detected.'; - return TicketResource::make($ticket->fresh()->load([ + $canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project); + $displayTicket = $ticket->fresh([ 'assignee', 'author', 'fieldValues.ticketFieldDefinition', @@ -132,7 +133,23 @@ class ProjectTicketController extends ApiController 'ticketStatus.boardColumn', 'ticketType', 'worklogEntries.actor', - ])) + ]) ?? $ticket->load([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + 'worklogEntries.actor', + ]); + + if (! $project->show_author && ! $canManageProject) { + $displayTicket->setRelation('author', null); + $displayTicket->author_user_id = null; + } + + return TicketResource::make($displayTicket) ->additional([ 'meta' => [ 'message' => $statusMessage, diff --git a/tests/Feature/Api/TicketApiTest.php b/tests/Feature/Api/TicketApiTest.php index 3a4f044..e02880a 100644 --- a/tests/Feature/Api/TicketApiTest.php +++ b/tests/Feature/Api/TicketApiTest.php @@ -81,6 +81,77 @@ class TicketApiTest extends TestCase ->assertJsonPath('data.ticket.story_points', 5); } + public function test_ticket_update_hides_author_for_non_admins_when_author_visibility_is_disabled(): void + { + $writer = User::factory()->create(); + $author = User::factory()->create([ + 'name' => 'Author User', + ]); + $project = Project::factory()->create([ + 'key' => 'CORE', + 'show_author' => false, + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $priority = $project->ticketPriorities()->firstOrFail(); + $status = $project->ticketStatuses()->firstOrFail(); + $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); + + $ticket = Ticket::factory()->create([ + 'author_user_id' => $author->id, + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $status->id, + 'ticket_type_id' => $type->id, + 'title' => 'API ticket', + ]); + + $this->syncProjectPermissions($project, $writer, ['write', 'view']); + + $this->actingAs($writer) + ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ + 'acceptance_criteria' => $ticket->acceptance_criteria, + 'blocked_comment' => $ticket->blocked_comment, + 'description' => $ticket->description, + 'is_blocked' => $ticket->is_blocked, + 'parent_ticket_id' => $ticket->parent_ticket_id, + 'story_points' => $ticket->story_points, + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $type->id, + 'title' => 'Updated API ticket', + ]) + ->assertOk() + ->assertJsonPath('data.author', null) + ->assertJsonPath('data.author_user_id', null); + } + + public function test_ticket_options_only_return_project_visible_users(): void + { + [$user, $project] = $this->projectContext(); + $projectViewer = User::factory()->create(); + $outsider = User::factory()->create(); + + $this->syncProjectPermissions($project, $projectViewer, ['view']); + + $response = $this->actingAs($user) + ->getJson(route('api.projects.ticket-options', [ + 'project' => $project, + 'source' => 'backlog', + ])) + ->assertOk(); + + $userIds = collect($response->json('data.users')) + ->pluck('id') + ->all(); + + $this->assertContains($user->id, $userIds); + $this->assertContains($projectViewer->id, $userIds); + $this->assertNotContains($outsider->id, $userIds); + } + public function test_tickets_can_return_status_targets_and_move_statuses(): void { [$user, $project] = $this->projectContext();