From 8b25421e7ce90bc5b6ab1001077e93be9201d25f Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 13:22:56 +0200 Subject: [PATCH 01/25] 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 f036845f068dd660fb45a96608ccb64155a1f7fe Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 13:36:07 +0200 Subject: [PATCH 02/25] Expose public Scalar API docs --- composer.json | 3 +- composer.lock | 132 +++++++++++- config/scalar.php | 198 ++++++++++++++++++ routes/web.php | 12 ++ .../Api/PublicApiDocumentationTest.php | 37 ++++ 5 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 config/scalar.php create mode 100644 tests/Feature/Api/PublicApiDocumentationTest.php diff --git a/composer.json b/composer.json index cc4e39c..da9a5c3 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "laravel/fortify": "^1.34", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", - "laravel/wayfinder": "^0.1.14" + "laravel/wayfinder": "^0.1.14", + "scalar/laravel": "^0.2.1" }, "require-dev": { "fakerphp/faker": "^1.24", diff --git a/composer.lock b/composer.lock index 8f8e5d1..aa93906 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c55625a323719bb164dc4b9b763d6ede", + "content-hash": "896a8244956aa86a482b054e2616a90f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -3770,6 +3770,136 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "scalar/laravel", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/scalar/laravel.git", + "reference": "5f6dace4740a082df5058804430f12af01925810" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scalar/laravel/zipball/5f6dace4740a082df5058804430f12af01925810", + "reference": "5f6dace4740a082df5058804430f12af01925810", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0||^11.0||^12.0||^13.0", + "php": "^8.2||^8.3", + "spatie/laravel-package-tools": "^1.16" + }, + "require-dev": { + "laravel/pint": "^1.14", + "nunomaduro/collision": "^8.7.0||^7.10.0", + "orchestra/testbench": "^8.0||^9.0||^10.0||^11.0", + "pestphp/pest": "^2.3.4||^3.7.4||^4.0", + "pestphp/pest-plugin-arch": "^2.7.0||^3.0.0||^4.0", + "pestphp/pest-plugin-laravel": "^2.3.0||^3.1.0||^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Scalar": "Scalar\\Facades\\Scalar" + }, + "providers": [ + "Scalar\\ScalarServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Scalar\\": "src/", + "Scalar\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Hans Pagel", + "email": "mail@hanspagel.com", + "role": "Developer" + } + ], + "description": "Render your OpenAPI-based API reference", + "homepage": "https://github.com/scalar/laravel", + "keywords": [ + "laravel", + "openapi", + "scalar", + "swagger" + ], + "support": { + "issues": "https://github.com/scalar/laravel/issues", + "source": "https://github.com/scalar/laravel/tree/0.2.1" + }, + "time": "2026-03-23T14:14:34+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-02-21T12:49:54+00:00" + }, { "name": "symfony/clock", "version": "v8.0.8", diff --git a/config/scalar.php b/config/scalar.php new file mode 100644 index 0000000..23e1e05 --- /dev/null +++ b/config/scalar.php @@ -0,0 +1,198 @@ + null, + + /* + |-------------------------------------------------------------------------- + | Scalar Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Scalar will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + 'path' => '/api/docs', + + /* + |-------------------------------------------------------------------------- + | Scalar Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will get attached onto each Scalar route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Scalar OpenAPI Document URL + |-------------------------------------------------------------------------- + | + | This is the URL to the OpenAPI document that Scalar will use to generate + | the API reference. By default, it points to the latest version of the + | Scalar Galaxy package. You can change this to use a custom OpenAPI file. + | + */ + 'url' => '/api/docs/openapi.json', + + /* + |-------------------------------------------------------------------------- + | Scalar CDN URL + |-------------------------------------------------------------------------- + | + | This is the URL to the CDN where Scalar's API reference assets are hosted. + | By default, it points to the jsDelivr CDN for the @scalar/api-reference + | package. You can change this if you want to use a different CDN. + | + */ + 'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference', + + /* + |-------------------------------------------------------------------------- + | Scalar Configuration + |-------------------------------------------------------------------------- + | + | The configuration options for the Scalar API reference. This array + | contains all the settings that control the behavior and appearance + | of the API documentation. + | + */ + 'configuration' => [ + /** A string to use one of the color presets */ + 'theme' => + // 'alternate', + // 'bluePlanet', + // 'deepSpace', + // 'default', + // 'kepler', + 'laravel', + // 'mars', + // 'moon', + // 'purple', + // 'saturn', + // 'solarized', + // 'none', + + /** The layout to use for the references */ + 'layout' => 'modern', + + /** URL to a request proxy for the API client */ + 'proxyUrl' => 'https://proxy.scalar.com', + + /** Whether to show the sidebar */ + 'showSidebar' => true, + + /** + * Whether to show models in the sidebar, search, and content. + */ + 'hideModels' => false, + + /** + * Whether to show the “Download OpenAPI Document” button + */ + 'hideDownloadButton' => false, + + /** + * Whether to show the “Test Request” button + */ + 'hideTestRequestButton' => true, + + /** + * Whether to show the sidebar search bar + */ + 'hideSearch' => false, + + /** Whether dark mode is on or off initially (light mode) */ + 'darkMode' => false, + + /** forceDarkModeState makes it always this state no matter what*/ + 'forceDarkModeState' => 'dark', + + /** Whether to show the dark mode toggle */ + 'hideDarkModeToggle' => false, + + /** Key used with CTRL/CMD to open the search modal (defaults to 'k' e.g. CMD+k) */ + 'searchHotKey' => 'k', + + /** + * If used, passed data will be added to the HTML header + * + * @see https://unhead.unjs.io/usage/composables/use-seo-meta + */ + 'metaData' => [ + 'title' => config('app.name').' API Reference', + ], + + /** + * Path to a favicon image + * + * @example '/favicon.svg' + */ + 'favicon' => '', + + /** + * List of httpsnippet clients to hide from the clients menu + * By default hides Unirest, pass `[]` to show all clients + */ + 'hiddenClients' => [ + + ], + + /** Determine the HTTP client that’s selected by default */ + 'defaultHttpClient' => [ + 'targetId' => 'shell', + 'clientKey' => 'curl', + ], + + /** Custom CSS to be added to the page */ + // 'customCss' => '', + + /** Prefill authentication */ + // 'authentication' => [ + // // TODO + // ], + + /** + * The baseServerURL is used when the spec servers are relative paths and we are using SSR. + * On the client we can grab the window.location.origin but on the server we need + * to use this prop. + */ + // 'baseServerURL' => '', + + /** + * List of servers to override the openapi spec servers + */ + // 'servers' => [ + // [ + // 'url' => 'https://api.scalar.com', + // 'description' => 'Production server', + // ], + // ], + + /** + * We’re using Inter and JetBrains Mono as the default fonts. If you want to use your own fonts, set this to false. + */ + 'withDefaultFonts' => true, + + /** + * By default we only open the relevant tag based on the url, however if you want all the tags open by default then set this configuration option :) + */ + 'defaultOpenAllTags' => false, + ], + +]; diff --git a/routes/web.php b/routes/web.php index d12eeb7..ec9722b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ Features::enabled(Features::registration()), ])->name('home'); +Route::get('api/docs/openapi.json', function (): Response { + $path = base_path('docs/openapi/kanbangit-api.openapi.json'); + + abort_unless(File::exists($path), 404); + + return response(File::get($path), 200, [ + 'Content-Type' => 'application/vnd.oai.openapi+json; charset=utf-8', + ]); +})->name('api.docs.openapi'); + Route::middleware(['auth', 'verified'])->group(function () { Route::inertia('dashboard', 'Dashboard')->name('dashboard'); diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php new file mode 100644 index 0000000..3623399 --- /dev/null +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -0,0 +1,37 @@ +get('/api/docs') + ->assertOk(); + } + + public function test_openapi_document_is_publicly_served(): void + { + $response = $this->get('/api/docs/openapi.json') + ->assertOk(); + + $this->assertStringStartsWith( + 'application/vnd.oai.openapi+json', + (string) $response->headers->get('Content-Type'), + ); + + $document = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame('3.1.0', $document['openapi']); + $this->assertSame([], $document['security']); + $this->assertArrayHasKey('/projects', $document['paths']); + } + + public function test_application_api_still_requires_an_authenticated_user(): void + { + $this->getJson('/api/projects') + ->assertUnauthorized(); + } +} From db9e4a7312734a893f69a2ae6acf61036d24a16e Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 13:40:52 +0200 Subject: [PATCH 03/25] Disable Scalar MCP prompt --- config/scalar.php | 7 +++++++ tests/Feature/Api/PublicApiDocumentationTest.php | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/config/scalar.php b/config/scalar.php index 23e1e05..ff5c8d4 100644 --- a/config/scalar.php +++ b/config/scalar.php @@ -112,6 +112,13 @@ return [ */ 'hideTestRequestButton' => true, + /** + * MCP will be introduced as a first-party KanbanGit feature later. + */ + 'mcp' => [ + 'disabled' => true, + ], + /** * Whether to show the sidebar search bar */ diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php index 3623399..2c50d43 100644 --- a/tests/Feature/Api/PublicApiDocumentationTest.php +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -9,7 +9,8 @@ class PublicApiDocumentationTest extends TestCase public function test_scalar_api_docs_are_public(): void { $this->get('/api/docs') - ->assertOk(); + ->assertOk() + ->assertSee('"mcp":{"disabled":true}', false); } public function test_openapi_document_is_publicly_served(): void From a6a4615ad50a67c5664c399c3820d8a6d1c82ad4 Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 13:44:42 +0200 Subject: [PATCH 04/25] Hide Scalar API client button --- config/scalar.php | 5 +++++ tests/Feature/Api/PublicApiDocumentationTest.php | 1 + 2 files changed, 6 insertions(+) diff --git a/config/scalar.php b/config/scalar.php index ff5c8d4..e750dee 100644 --- a/config/scalar.php +++ b/config/scalar.php @@ -112,6 +112,11 @@ return [ */ 'hideTestRequestButton' => true, + /** + * Whether to show the API client button in the sidebar and modal. + */ + 'hideClientButton' => true, + /** * MCP will be introduced as a first-party KanbanGit feature later. */ diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php index 2c50d43..d06a961 100644 --- a/tests/Feature/Api/PublicApiDocumentationTest.php +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -10,6 +10,7 @@ class PublicApiDocumentationTest extends TestCase { $this->get('/api/docs') ->assertOk() + ->assertSee('"hideClientButton":true', false) ->assertSee('"mcp":{"disabled":true}', false); } From bf0e50464adcb9b75e8db221b64ac45909be595b Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 19:58:24 +0200 Subject: [PATCH 05/25] Add application API keys --- .../ListAssignedTicketsByVisibleProject.php | 57 +++++ .../CreateUserApplication.php | 30 +++ .../DeleteUserApplication.php | 17 ++ .../Api/UserAssignedTicketController.php | 28 +++ .../Settings/UserApplicationController.php | 57 +++++ .../AuthenticateApiApplicationKey.php | 44 ++++ .../Settings/StoreUserApplicationRequest.php | 37 ++++ .../Resources/UserApplicationResource.php | 26 +++ app/Models/User.php | 7 + app/Models/UserApplication.php | 44 ++++ bootstrap/app.php | 5 + database/factories/UserApplicationFactory.php | 30 +++ ..._174556_create_user_applications_table.php | 33 +++ docs/openapi/kanbangit-api.openapi.json | 92 +++++++- resources/js/layouts/settings/Layout.vue | 5 + resources/js/pages/settings/Applications.vue | 198 ++++++++++++++++++ .../js/types/models/user-application-model.ts | 8 + resources/js/types/page-props.ts | 7 + routes/api.php | 2 + routes/api/users.php | 11 + routes/web/settings.php | 10 + tests/Feature/Api/ApiApplicationKeyTest.php | 84 ++++++++ tests/Feature/Api/AssignedTicketsApiTest.php | 80 +++++++ .../Feature/Api/OpenApiDocumentationTest.php | 8 +- tests/Feature/Api/ProjectApiTest.php | 10 +- .../Api/PublicApiDocumentationTest.php | 2 +- tests/Feature/Api/SettingsApiTest.php | 50 ++--- tests/Feature/Api/TicketApiTest.php | 24 +-- tests/Feature/Models/UserApplicationTest.php | 40 ++++ .../Feature/Settings/UserApplicationTest.php | 103 +++++++++ tests/TestCase.php | 21 ++ 31 files changed, 1124 insertions(+), 46 deletions(-) create mode 100644 app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php create mode 100644 app/Actions/UserApplications/CreateUserApplication.php create mode 100644 app/Actions/UserApplications/DeleteUserApplication.php create mode 100644 app/Http/Controllers/Api/UserAssignedTicketController.php create mode 100644 app/Http/Controllers/Settings/UserApplicationController.php create mode 100644 app/Http/Middleware/AuthenticateApiApplicationKey.php create mode 100644 app/Http/Requests/Settings/StoreUserApplicationRequest.php create mode 100644 app/Http/Resources/UserApplicationResource.php create mode 100644 app/Models/UserApplication.php create mode 100644 database/factories/UserApplicationFactory.php create mode 100644 database/migrations/2026_04_20_174556_create_user_applications_table.php create mode 100644 resources/js/pages/settings/Applications.vue create mode 100644 resources/js/types/models/user-application-model.ts create mode 100644 routes/api/users.php create mode 100644 tests/Feature/Api/ApiApplicationKeyTest.php create mode 100644 tests/Feature/Api/AssignedTicketsApiTest.php create mode 100644 tests/Feature/Models/UserApplicationTest.php create mode 100644 tests/Feature/Settings/UserApplicationTest.php diff --git a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php new file mode 100644 index 0000000..f31200a --- /dev/null +++ b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php @@ -0,0 +1,57 @@ +, tickets: array>}> + */ + public function handle(User $viewer, User $assignee): array + { + /** @var EloquentCollection $visibleProjects */ + $visibleProjects = $this->listVisibleProjects->handle($viewer); + $visibleProjectIds = $visibleProjects->pluck('id')->all(); + + if ($visibleProjectIds === []) { + return []; + } + + /** @var Collection> $ticketsByProject */ + $ticketsByProject = Ticket::query() + ->where('assignee_user_id', $assignee->id) + ->whereIn('project_id', $visibleProjectIds) + ->with([ + 'assignee', + 'project', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + ]) + ->orderByDesc('sequence_number') + ->get() + ->groupBy('project_id'); + + return $visibleProjects + ->filter(fn (Project $project): bool => $ticketsByProject->has($project->id)) + ->map(fn (Project $project): array => [ + 'project' => ProjectResource::make($project)->resolve(), + 'tickets' => TicketResource::collection($ticketsByProject->get($project->id))->resolve(), + ]) + ->values() + ->all(); + } +} diff --git a/app/Actions/UserApplications/CreateUserApplication.php b/app/Actions/UserApplications/CreateUserApplication.php new file mode 100644 index 0000000..4d8b8ed --- /dev/null +++ b/app/Actions/UserApplications/CreateUserApplication.php @@ -0,0 +1,30 @@ +create([ + 'key_hash' => UserApplication::hashKey($plainTextKey), + 'name' => Arr::get($validated, 'name'), + 'user_id' => $user->id, + ]); + + return [ + 'application' => $application, + 'plain_text_key' => $plainTextKey, + ]; + } +} diff --git a/app/Actions/UserApplications/DeleteUserApplication.php b/app/Actions/UserApplications/DeleteUserApplication.php new file mode 100644 index 0000000..f9bcead --- /dev/null +++ b/app/Actions/UserApplications/DeleteUserApplication.php @@ -0,0 +1,17 @@ +name; + + $application->delete(); + + return $name; + } +} diff --git a/app/Http/Controllers/Api/UserAssignedTicketController.php b/app/Http/Controllers/Api/UserAssignedTicketController.php new file mode 100644 index 0000000..c750955 --- /dev/null +++ b/app/Http/Controllers/Api/UserAssignedTicketController.php @@ -0,0 +1,28 @@ +authenticatedUser($request); + + return response()->json([ + 'data' => $this->listAssignedTicketsByVisibleProject->handle($viewer, $user), + 'meta' => [ + 'assignee' => UserResource::make($user)->resolve(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Settings/UserApplicationController.php b/app/Http/Controllers/Settings/UserApplicationController.php new file mode 100644 index 0000000..52509f0 --- /dev/null +++ b/app/Http/Controllers/Settings/UserApplicationController.php @@ -0,0 +1,57 @@ + $request->session()->get('applicationKey'), + 'applications' => UserApplicationResource::collection($request->user()->userApplications)->resolve(), + 'status' => $request->session()->get('status'), + ]); + } + + public function store(StoreUserApplicationRequest $request): RedirectResponse + { + $result = $this->createUserApplication->handle($request->user(), $request->validated()); + + return to_route('applications.edit') + ->with('applicationKey', $result['plain_text_key']) + ->with('status', "{$result['application']->name} was created. Save this key now; it will not be shown again."); + } + + public function destroy(Request $request, UserApplication $userApplication): RedirectResponse + { + $this->abortUnlessOwner($request, $userApplication); + + $name = $this->deleteUserApplication->handle($userApplication); + + return to_route('applications.edit') + ->with('status', "{$name} was removed."); + } + + private function abortUnlessOwner(Request $request, UserApplication $userApplication): void + { + $user = $request->user(); + + abort_unless($userApplication->user_id === $user->id, 403); + } +} diff --git a/app/Http/Middleware/AuthenticateApiApplicationKey.php b/app/Http/Middleware/AuthenticateApiApplicationKey.php new file mode 100644 index 0000000..8dc1405 --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiApplicationKey.php @@ -0,0 +1,44 @@ +bearerToken(); + + if ($plainTextKey === null || trim($plainTextKey) === '') { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + $application = UserApplication::query() + ->with('user') + ->where('key_hash', UserApplication::hashKey($plainTextKey)) + ->first(); + + if ($application === null || $application->user === null) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + Auth::setUser($application->user); + $request->setUserResolver(fn () => $application->user); + + $application->forceFill([ + 'last_used_at' => now(), + ])->save(); + + return $next($request); + } +} diff --git a/app/Http/Requests/Settings/StoreUserApplicationRequest.php b/app/Http/Requests/Settings/StoreUserApplicationRequest.php new file mode 100644 index 0000000..dc2b8d1 --- /dev/null +++ b/app/Http/Requests/Settings/StoreUserApplicationRequest.php @@ -0,0 +1,37 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'name' => Str::of((string) $this->input('name'))->trim()->value(), + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Resources/UserApplicationResource.php b/app/Http/Resources/UserApplicationResource.php new file mode 100644 index 0000000..11b69e0 --- /dev/null +++ b/app/Http/Resources/UserApplicationResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'user_id' => $this->user_id, + 'name' => $this->name, + 'last_used_at' => $this->last_used_at?->toISOString(), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ef51f81..a1cc99d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -60,6 +60,13 @@ class User extends Authenticatable ->orderBy('base_url'); } + public function userApplications(): HasMany + { + return $this->hasMany(UserApplication::class) + ->orderBy('name') + ->orderBy('id'); + } + public function assignedTickets(): HasMany { return $this->hasMany(Ticket::class, 'assignee_user_id'); diff --git a/app/Models/UserApplication.php b/app/Models/UserApplication.php new file mode 100644 index 0000000..98ea81c --- /dev/null +++ b/app/Models/UserApplication.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + public static function generatePlainTextKey(): string + { + return 'kg_app_'.Str::random(64); + } + + public static function hashKey(string $plainTextKey): string + { + return hash_hmac('sha256', $plainTextKey, (string) config('app.key')); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'last_used_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index efb547c..001ee1c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ api(append: [ + AuthenticateApiApplicationKey::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/database/factories/UserApplicationFactory.php b/database/factories/UserApplicationFactory.php new file mode 100644 index 0000000..1c36d79 --- /dev/null +++ b/database/factories/UserApplicationFactory.php @@ -0,0 +1,30 @@ + + */ +class UserApplicationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $plainTextKey = UserApplication::generatePlainTextKey(); + + return [ + 'key_hash' => UserApplication::hashKey($plainTextKey), + 'last_used_at' => null, + 'name' => fake()->words(2, true), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2026_04_20_174556_create_user_applications_table.php b/database/migrations/2026_04_20_174556_create_user_applications_table.php new file mode 100644 index 0000000..e0809a4 --- /dev/null +++ b/database/migrations/2026_04_20_174556_create_user_applications_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('key_hash', 64)->unique(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_applications'); + } +}; diff --git a/docs/openapi/kanbangit-api.openapi.json b/docs/openapi/kanbangit-api.openapi.json index 1d6dca8..9a1a607 100644 --- a/docs/openapi/kanbangit-api.openapi.json +++ b/docs/openapi/kanbangit-api.openapi.json @@ -3,14 +3,18 @@ "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." + "description": "First-party JSON API for KanbanGit UI-equivalent workflows. Application endpoints require a bearer application key created in Settings > Applications." }, "servers": [ { "url": "/api" } ], - "security": [], + "security": [ + { + "ApplicationKey": [] + } + ], "tags": [ { "name": "Projects" @@ -29,6 +33,9 @@ }, { "name": "Git Connections" + }, + { + "name": "Users" } ], "paths": { @@ -1041,10 +1048,45 @@ } } } + }, + "/users/{user}/assigned-tickets": { + "get": { + "tags": ["Users"], + "operationId": "getUserAssignedTickets", + "parameters": [ + { + "$ref": "#/components/parameters/UserId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/AssignedTicketsPayload" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + } + } + } } }, "components": { + "securitySchemes": { + "ApplicationKey": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "KanbanGit application key", + "description": "Use a first-party application key created in Settings > Applications." + } + }, "parameters": { + "UserId": { + "name": "user", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, "ProjectId": { "name": "project", "in": "path", @@ -1600,6 +1642,16 @@ } } }, + "AssignedTicketsPayload": { + "description": "Tickets assigned to a user grouped by projects visible to the authenticated application key owner.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignedTicketsPayloadEnvelope" + } + } + } + }, "ValidationError": { "description": "Laravel validation error response.", "content": { @@ -1779,6 +1831,42 @@ } } }, + "AssignedTicketsPayloadEnvelope": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssignedTicketsProjectGroup" + } + }, + "meta": { + "type": "object", + "properties": { + "assignee": { + "$ref": "#/components/schemas/User" + } + }, + "required": ["assignee"] + } + }, + "required": ["data", "meta"] + }, + "AssignedTicketsProjectGroup": { + "type": "object", + "properties": { + "project": { + "$ref": "#/components/schemas/Project" + }, + "tickets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ticket" + } + } + }, + "required": ["project", "tickets"] + }, "BoardColumn": { "type": "object", "additionalProperties": true, diff --git a/resources/js/layouts/settings/Layout.vue b/resources/js/layouts/settings/Layout.vue index 40d4d88..879a8cb 100644 --- a/resources/js/layouts/settings/Layout.vue +++ b/resources/js/layouts/settings/Layout.vue @@ -6,6 +6,7 @@ import { Separator } from '@/components/ui/separator'; import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { toUrl } from '@/lib/utils'; import { edit as editAppearance } from '@/routes/appearance/index'; +import { edit as editApplications } from '@/routes/applications/index'; import { edit as editGitConnections } from '@/routes/git-connections/index'; import { edit as editProfile } from '@/routes/profile/index'; import { edit as editSecurity } from '@/routes/security/index'; @@ -24,6 +25,10 @@ const sidebarNavItems: NavItem[] = [ title: 'Git connections', href: editGitConnections(), }, + { + title: 'Applications', + href: editApplications(), + }, { title: 'Appearance', href: editAppearance(), diff --git a/resources/js/pages/settings/Applications.vue b/resources/js/pages/settings/Applications.vue new file mode 100644 index 0000000..acb8a08 --- /dev/null +++ b/resources/js/pages/settings/Applications.vue @@ -0,0 +1,198 @@ + + + diff --git a/resources/js/types/models/user-application-model.ts b/resources/js/types/models/user-application-model.ts new file mode 100644 index 0000000..b4d8a96 --- /dev/null +++ b/resources/js/types/models/user-application-model.ts @@ -0,0 +1,8 @@ +export type UserApplicationModel = { + id: number; + user_id: number; + name: string; + last_used_at: string | null; + created_at: string | null; + updated_at: string | null; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index e52c4eb..5a102ac 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -14,6 +14,7 @@ import type { TicketPriorityModel } from '@/types/models/ticket-priority-model'; import type { TicketStatusModel } from '@/types/models/ticket-status-model'; import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model'; import type { TicketTypeModel } from '@/types/models/ticket-type-model'; +import type { UserApplicationModel } from '@/types/models/user-application-model'; import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model'; import type { UserModel } from '@/types/models/user-model'; @@ -150,3 +151,9 @@ export type SettingsGitConnectionsPageProps = { gitConnections: UserGitConnectionModel[]; status?: string | null; }; + +export type SettingsApplicationsPageProps = { + applicationKey?: string | null; + applications: UserApplicationModel[]; + status?: string | null; +}; diff --git a/routes/api.php b/routes/api.php index 630ab56..7a864a6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,3 +3,5 @@ require __DIR__.'/api/projects.php'; require __DIR__.'/api/settings.php'; + +require __DIR__.'/api/users.php'; diff --git a/routes/api/users.php b/routes/api/users.php new file mode 100644 index 0000000..52f8708 --- /dev/null +++ b/routes/api/users.php @@ -0,0 +1,11 @@ +name('api.users.') + ->group(function (): void { + Route::get('{user}/assigned-tickets', UserAssignedTicketController::class) + ->name('assigned-tickets'); + }); diff --git a/routes/web/settings.php b/routes/web/settings.php index c03052c..21827fc 100644 --- a/routes/web/settings.php +++ b/routes/web/settings.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Settings\GitConnectionController; use App\Http\Controllers\Settings\ProfileController; use App\Http\Controllers\Settings\SecurityController; +use App\Http\Controllers\Settings\UserApplicationController; use Illuminate\Support\Facades\Route; Route::middleware('auth') @@ -41,5 +42,14 @@ Route::middleware(['auth', 'verified']) Route::delete('{gitConnection}', 'destroy')->name('destroy'); }); + Route::prefix('applications') + ->name('applications.') + ->controller(UserApplicationController::class) + ->group(function (): void { + Route::get('/', 'edit')->name('edit'); + Route::post('/', 'store')->name('store'); + Route::delete('{userApplication}', 'destroy')->name('destroy'); + }); + Route::inertia('appearance', 'settings/Appearance')->name('appearance.edit'); }); diff --git a/tests/Feature/Api/ApiApplicationKeyTest.php b/tests/Feature/Api/ApiApplicationKeyTest.php new file mode 100644 index 0000000..cd85681 --- /dev/null +++ b/tests/Feature/Api/ApiApplicationKeyTest.php @@ -0,0 +1,84 @@ +getJson(route('api.projects.index')) + ->assertUnauthorized() + ->assertJsonPath('message', 'Unauthenticated.'); + } + + public function test_invalid_bearer_token_returns_unauthorized(): void + { + $this->withHeader('Authorization', 'Bearer wrong-key') + ->getJson(route('api.projects.index')) + ->assertUnauthorized() + ->assertJsonPath('message', 'Unauthenticated.'); + } + + public function test_valid_bearer_token_authenticates_as_owner(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'name' => 'Visible project', + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + $this->withApplicationKey($user) + ->getJson(route('api.projects.index')) + ->assertOk() + ->assertJsonPath('data.0.name', 'Visible project') + ->assertJsonCount(1, 'data'); + } + + public function test_application_key_owner_permissions_still_apply(): void + { + $user = User::factory()->create(); + $visibleProject = Project::factory()->create([ + 'name' => 'Visible project', + ]); + Project::factory()->create([ + 'name' => 'Hidden project', + ]); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + + $this->withApplicationKey($user) + ->getJson(route('api.projects.index')) + ->assertOk() + ->assertJsonPath('data.0.name', 'Visible project') + ->assertJsonCount(1, 'data'); + } + + public function test_application_last_used_at_updates_after_use(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $plainTextKey = UserApplication::generatePlainTextKey(); + $application = UserApplication::query()->create([ + 'key_hash' => UserApplication::hashKey($plainTextKey), + 'name' => 'Claude Desktop', + 'user_id' => $user->id, + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + $this->withHeader('Authorization', "Bearer {$plainTextKey}") + ->getJson(route('api.projects.index')) + ->assertOk(); + + $this->assertNotNull($application->fresh()->last_used_at); + } +} diff --git a/tests/Feature/Api/AssignedTicketsApiTest.php b/tests/Feature/Api/AssignedTicketsApiTest.php new file mode 100644 index 0000000..196f39e --- /dev/null +++ b/tests/Feature/Api/AssignedTicketsApiTest.php @@ -0,0 +1,80 @@ +create(); + $assignee = User::factory()->create([ + 'name' => 'Assigned User', + ]); + $otherAssignee = User::factory()->create(); + + $olderProject = Project::factory()->create([ + 'key' => 'OLD', + 'name' => 'Older project', + ]); + $newerProject = Project::factory()->create([ + 'key' => 'NEW', + 'name' => 'Newer project', + ]); + $hiddenProject = Project::factory()->create([ + 'key' => 'HID', + 'name' => 'Hidden project', + ]); + + $this->syncProjectPermissions($olderProject, $viewer, ['view']); + $this->syncProjectPermissions($newerProject, $viewer, ['view']); + + Ticket::factory()->create([ + 'assignee_user_id' => $assignee->id, + 'project_id' => $olderProject->id, + 'sequence_number' => 1, + 'ticket_key' => 'OLD-1', + 'title' => 'Older visible ticket', + ]); + Ticket::factory()->create([ + 'assignee_user_id' => $assignee->id, + 'project_id' => $newerProject->id, + 'sequence_number' => 2, + 'ticket_key' => 'NEW-2', + 'title' => 'Newer visible ticket', + ]); + Ticket::factory()->create([ + 'assignee_user_id' => $assignee->id, + 'project_id' => $hiddenProject->id, + 'sequence_number' => 1, + 'ticket_key' => 'HID-1', + 'title' => 'Hidden ticket', + ]); + Ticket::factory()->create([ + 'assignee_user_id' => $otherAssignee->id, + 'project_id' => $olderProject->id, + 'sequence_number' => 3, + 'ticket_key' => 'OLD-3', + 'title' => 'Other user ticket', + ]); + + $this->withApplicationKey($viewer) + ->getJson(route('api.users.assigned-tickets', $assignee)) + ->assertOk() + ->assertJsonPath('meta.assignee.name', 'Assigned User') + ->assertJsonPath('data.0.project.name', 'Newer project') + ->assertJsonPath('data.0.tickets.0.ticket_key', 'NEW-2') + ->assertJsonCount(1, 'data.0.tickets') + ->assertJsonPath('data.1.project.name', 'Older project') + ->assertJsonPath('data.1.tickets.0.ticket_key', 'OLD-1') + ->assertJsonCount(1, 'data.1.tickets') + ->assertJsonCount(2, 'data'); + } +} diff --git a/tests/Feature/Api/OpenApiDocumentationTest.php b/tests/Feature/Api/OpenApiDocumentationTest.php index e6bd11c..db2ae00 100644 --- a/tests/Feature/Api/OpenApiDocumentationTest.php +++ b/tests/Feature/Api/OpenApiDocumentationTest.php @@ -17,7 +17,7 @@ class OpenApiDocumentationTest extends TestCase $this->assertIsArray($document); $this->assertSame(JSON_ERROR_NONE, json_last_error()); $this->assertSame('3.1.0', $document['openapi']); - $this->assertSame([], $document['security']); + $this->assertSame([['ApplicationKey' => []]], $document['security']); $expectedOperations = [ ['get', '/projects'], @@ -67,6 +67,7 @@ class OpenApiDocumentationTest extends TestCase ['post', '/git-connections'], ['patch', '/git-connections/{gitConnection}'], ['delete', '/git-connections/{gitConnection}'], + ['get', '/users/{user}/assigned-tickets'], ]; foreach ($expectedOperations as [$method, $path]) { @@ -74,8 +75,13 @@ class OpenApiDocumentationTest extends TestCase $this->assertArrayHasKey($method, $document['paths'][$path]); } + $this->assertArrayHasKey('ApplicationKey', $document['components']['securitySchemes']); + $this->assertSame('http', $document['components']['securitySchemes']['ApplicationKey']['type']); + $this->assertSame('bearer', $document['components']['securitySchemes']['ApplicationKey']['scheme']); $this->assertArrayHasKey('ValidationError', $document['components']['schemas']); $this->assertArrayHasKey('TicketUpdateRequest', $document['components']['schemas']); $this->assertArrayHasKey('UserGitConnectionRequest', $document['components']['schemas']); + $this->assertArrayHasKey('AssignedTicketsPayloadEnvelope', $document['components']['schemas']); + $this->assertArrayHasKey('AssignedTicketsProjectGroup', $document['components']['schemas']); } } diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index 9e263e2..c4bbf93 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -24,7 +24,7 @@ class ProjectApiTest extends TestCase $this->syncProjectPermissions($visibleProject, $user, ['view']); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.index')) ->assertOk() ->assertJsonPath('data.0.name', 'Visible project') @@ -36,7 +36,7 @@ class ProjectApiTest extends TestCase $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['create']); - $this->actingAs($user) + $this->withApplicationKey($user) ->postJson(route('api.projects.store'), [ 'key' => 'api', 'name' => 'API Project', @@ -61,20 +61,20 @@ class ProjectApiTest extends TestCase app(InitializeProjectTicketMetadata::class)->handle($project); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.show', $project)) ->assertForbidden(); $this->syncProjectPermissions($project, $user, ['view']); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.show', $project)) ->assertOk() ->assertJsonPath('data.key', 'CORE'); $this->syncGlobalPermissions($admin, ['admin']); - $this->actingAs($admin) + $this->withApplicationKey($admin) ->deleteJson(route('api.projects.destroy', $project), [ 'confirmation_key' => 'CORE', ]) diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php index d06a961..4886e24 100644 --- a/tests/Feature/Api/PublicApiDocumentationTest.php +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -27,7 +27,7 @@ class PublicApiDocumentationTest extends TestCase $document = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); $this->assertSame('3.1.0', $document['openapi']); - $this->assertSame([], $document['security']); + $this->assertSame([['ApplicationKey' => []]], $document['security']); $this->assertArrayHasKey('/projects', $document['paths']); } diff --git a/tests/Feature/Api/SettingsApiTest.php b/tests/Feature/Api/SettingsApiTest.php index b5ec766..110e656 100644 --- a/tests/Feature/Api/SettingsApiTest.php +++ b/tests/Feature/Api/SettingsApiTest.php @@ -17,12 +17,12 @@ class SettingsApiTest extends TestCase { [$user, $project] = $this->projectContext(); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.settings.board', $project)) ->assertOk() ->assertJsonPath('data.backlogTicketStatuses.0.name', 'Backlog'); - $columnId = $this->actingAs($user) + $columnId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.board.columns.store', $project), [ 'name' => 'In Review', ]) @@ -30,7 +30,7 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.name', 'In Review') ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.settings.board.columns.update', [$project, $columnId]), [ 'name' => 'Review', 'position' => 1, @@ -38,7 +38,7 @@ class SettingsApiTest extends TestCase ->assertOk() ->assertJsonPath('data.name', 'Review'); - $statusId = $this->actingAs($user) + $statusId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.board.statuses.store', $project), [ 'board_column_id' => $columnId, 'name' => 'Ready', @@ -46,7 +46,7 @@ class SettingsApiTest extends TestCase ->assertCreated() ->assertJsonPath('data.name', 'Ready') ->json('data.id'); - $replacementStatusId = $this->actingAs($user) + $replacementStatusId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.board.statuses.store', $project), [ 'board_column_id' => $columnId, 'name' => 'QA', @@ -56,14 +56,14 @@ class SettingsApiTest extends TestCase $backlogStatus = $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail(); - $this->actingAs($user) + $this->withApplicationKey($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) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.board.statuses.destroy', [$project, $statusId])) ->assertNoContent(); @@ -71,7 +71,7 @@ class SettingsApiTest extends TestCase 'id' => $replacementStatusId, ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.board.columns.destroy', [$project, $columnId])) ->assertNoContent(); } @@ -80,12 +80,12 @@ class SettingsApiTest extends TestCase { [$user, $project] = $this->projectContext(); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.settings.metadata', $project)) ->assertOk() ->assertJsonPath('data.ticketTypes.0.name', 'Task'); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.settings.metadata.update', $project), [ 'show_author' => false, 'show_story_points' => false, @@ -93,7 +93,7 @@ class SettingsApiTest extends TestCase ->assertOk() ->assertJsonPath('data.show_author', false); - $typeId = $this->actingAs($user) + $typeId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.metadata.types.store', $project), [ 'is_parent' => true, 'name' => 'Epic', @@ -102,7 +102,7 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.name', 'Epic') ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.settings.metadata.types.update', [$project, $typeId]), [ 'name' => 'Initiative', 'position' => 1, @@ -110,14 +110,14 @@ class SettingsApiTest extends TestCase ->assertOk() ->assertJsonPath('data.name', 'Initiative'); - $priorityId = $this->actingAs($user) + $priorityId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.metadata.priorities.store', $project), [ 'name' => 'Urgent', ]) ->assertCreated() ->json('data.id'); - $fieldId = $this->actingAs($user) + $fieldId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.metadata.fields.store', $project), [ 'field_type' => 'number', 'label' => 'Risk', @@ -125,26 +125,26 @@ class SettingsApiTest extends TestCase ->assertCreated() ->json('data.id'); - $linkTypeId = $this->actingAs($user) + $linkTypeId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.metadata.link-types.store', $project), [ 'name' => 'Relates to', ]) ->assertCreated() ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.metadata.fields.destroy', [$project, $fieldId])) ->assertNoContent(); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.metadata.link-types.destroy', [$project, $linkTypeId])) ->assertNoContent(); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.metadata.priorities.destroy', [$project, $priorityId])) ->assertNoContent(); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.metadata.types.destroy', [$project, $typeId])) ->assertNoContent(); } @@ -174,7 +174,7 @@ class SettingsApiTest extends TestCase [$user, $project] = $this->projectContext(); - $gitConnectionId = $this->actingAs($user) + $gitConnectionId = $this->withApplicationKey($user) ->postJson(route('api.git-connections.store'), [ 'api_token' => 'secret-token', 'base_url' => 'https://git.example.test/api/v1', @@ -184,12 +184,12 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.account_login', 'api-user') ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.git-connections.index')) ->assertOk() ->assertJsonPath('data.0.id', $gitConnectionId); - $repositoryConnectionId = $this->actingAs($user) + $repositoryConnectionId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.repositories.store', $project), [ 'provider_repository_id' => '501', 'user_git_connection_id' => $gitConnectionId, @@ -198,17 +198,17 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.repository_full_name', 'core-team/kanbangit') ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($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) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.repositories.destroy', [$project, $repositoryConnectionId])) ->assertNoContent(); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.git-connections.destroy', $gitConnectionId)) ->assertNoContent(); } diff --git a/tests/Feature/Api/TicketApiTest.php b/tests/Feature/Api/TicketApiTest.php index 3a4f044..5185153 100644 --- a/tests/Feature/Api/TicketApiTest.php +++ b/tests/Feature/Api/TicketApiTest.php @@ -28,7 +28,7 @@ class TicketApiTest extends TestCase $status = $project->ticketStatuses()->firstOrFail(); $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.ticket-options', [ 'project' => $project, 'source' => 'backlog', @@ -37,7 +37,7 @@ class TicketApiTest extends TestCase ->assertJsonPath('data.defaultTicketStatusId', $status->id) ->assertJsonPath('data.ticketTypes.0.id', $type->id); - $createResponse = $this->actingAs($user) + $createResponse = $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.store', $project), [ 'acceptance_criteria' => 'Can sign in', 'description' => 'Build the API-created ticket', @@ -53,12 +53,12 @@ class TicketApiTest extends TestCase $ticket = Ticket::query()->findOrFail($createResponse->json('data.id')); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.index', $project)) ->assertOk() ->assertJsonPath('data.0.ticket_key', 'CORE-1'); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ 'acceptance_criteria' => 'Can sign in from the API', 'blocked_comment' => null, @@ -74,7 +74,7 @@ class TicketApiTest extends TestCase ->assertJsonPath('data.title', 'Updated API ticket') ->assertJsonPath('meta.message', 'CORE-1 was updated.'); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.show', [$project, $ticket])) ->assertOk() ->assertJsonPath('data.ticket.ticket_key', 'CORE-1') @@ -101,13 +101,13 @@ class TicketApiTest extends TestCase $ticket = $this->ticket($project, $backlogStatus); - $this->actingAs($user) + $this->withApplicationKey($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) + $this->withApplicationKey($user) ->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [ 'ticket_status_id' => $doingStatus->id, ]) @@ -157,7 +157,7 @@ class TicketApiTest extends TestCase 'position' => 1, ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.links.store', [$project, $ticket]), [ 'linked_ticket_id' => $linkedTicket->id, 'ticket_link_type_id' => $linkType->id, @@ -181,7 +181,7 @@ class TicketApiTest extends TestCase 'provider' => GitProviderKey::Gitea, ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.branches.validate', [ 'project' => $project, 'ticket' => $ticket, @@ -191,7 +191,7 @@ class TicketApiTest extends TestCase ->assertOk() ->assertJsonPath('data.is_valid', true); - $this->actingAs($user) + $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.branches.store', [$project, $ticket]), [ 'branch_name' => 'core-1-api-ticket', 'repository_connection_id' => $repositoryConnection->id, @@ -219,12 +219,12 @@ class TicketApiTest extends TestCase 'position' => 1, ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.backlog', $project)) ->assertOk() ->assertJsonPath('data.tickets.0.id', $ticket->id); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.board', $project)) ->assertOk() ->assertJsonPath('data.boardColumns.0.name', 'Todo'); diff --git a/tests/Feature/Models/UserApplicationTest.php b/tests/Feature/Models/UserApplicationTest.php new file mode 100644 index 0000000..248256e --- /dev/null +++ b/tests/Feature/Models/UserApplicationTest.php @@ -0,0 +1,40 @@ +assertStringStartsWith('kg_app_', $plainTextKey); + $this->assertNotSame($plainTextKey, $keyHash); + $this->assertSame(64, strlen($keyHash)); + $this->assertSame($keyHash, UserApplication::hashKey($plainTextKey)); + + $application = UserApplication::query()->create([ + 'key_hash' => $keyHash, + 'name' => 'Claude Desktop', + 'user_id' => User::factory()->create()->id, + ]); + + $this->assertArrayNotHasKey('key_hash', $application->toArray()); + $this->assertDatabaseHas('user_applications', [ + 'id' => $application->id, + 'key_hash' => $keyHash, + ]); + $this->assertDatabaseMissing('user_applications', [ + 'id' => $application->id, + 'key_hash' => $plainTextKey, + ]); + } +} diff --git a/tests/Feature/Settings/UserApplicationTest.php b/tests/Feature/Settings/UserApplicationTest.php new file mode 100644 index 0000000..d80c6b3 --- /dev/null +++ b/tests/Feature/Settings/UserApplicationTest.php @@ -0,0 +1,103 @@ +create(); + UserApplication::factory()->for($user)->create([ + 'name' => 'Claude Desktop', + ]); + + $this->actingAs($user) + ->get(route('applications.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('settings/Applications') + ->where('applications.0.name', 'Claude Desktop') + ->where('applicationKey', null), + ); + } + + public function test_users_can_create_applications_and_see_plaintext_key_once(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->post(route('applications.store'), [ + 'name' => 'Claude Desktop', + ]) + ->assertRedirect(route('applications.edit')) + ->assertSessionHas('applicationKey') + ->assertSessionHas('status', 'Claude Desktop was created. Save this key now; it will not be shown again.'); + + $plainTextKey = (string) $response->baseResponse->getSession()->get('applicationKey'); + + $this->assertStringStartsWith('kg_app_', $plainTextKey); + $this->assertDatabaseHas('user_applications', [ + 'name' => 'Claude Desktop', + 'user_id' => $user->id, + 'key_hash' => UserApplication::hashKey($plainTextKey), + ]); + $this->assertDatabaseMissing('user_applications', [ + 'name' => 'Claude Desktop', + 'key_hash' => $plainTextKey, + ]); + + $this->actingAs($user) + ->get(route('applications.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('applicationKey', $plainTextKey), + ); + + $this->actingAs($user) + ->get(route('applications.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('applicationKey', null), + ); + } + + public function test_users_can_delete_their_own_applications(): void + { + $user = User::factory()->create(); + $application = UserApplication::factory()->for($user)->create([ + 'name' => 'Claude Desktop', + ]); + + $this->actingAs($user) + ->delete(route('applications.destroy', $application)) + ->assertRedirect(route('applications.edit')) + ->assertSessionHas('status', 'Claude Desktop was removed.'); + + $this->assertDatabaseMissing('user_applications', [ + 'id' => $application->id, + ]); + } + + public function test_users_cannot_delete_foreign_applications(): void + { + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $application = UserApplication::factory()->for($owner)->create(); + + $this->actingAs($otherUser) + ->delete(route('applications.destroy', $application)) + ->assertForbidden(); + + $this->assertDatabaseHas('user_applications', [ + 'id' => $application->id, + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 8a7bbf6..702aa58 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,6 +7,7 @@ use App\Enums\Permission; use App\Models\Project; use App\Models\Team; use App\Models\User; +use App\Models\UserApplication; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Laravel\Fortify\Features; @@ -55,6 +56,26 @@ abstract class TestCase extends BaseTestCase ); } + protected function withApplicationKey(User $user, ?string $name = null): static + { + $plainTextKey = $this->createPlainTextApplicationKey($user, $name); + + return $this->withHeader('Authorization', "Bearer {$plainTextKey}"); + } + + protected function createPlainTextApplicationKey(User $user, ?string $name = null): string + { + $plainTextKey = UserApplication::generatePlainTextKey(); + + UserApplication::query()->create([ + 'key_hash' => UserApplication::hashKey($plainTextKey), + 'name' => $name ?? 'Test application', + 'user_id' => $user->id, + ]); + + return $plainTextKey; + } + /** * @param array $permissions * @return array From cd574ec26bea23de60833c710defa6f7c22514a6 Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 20:36:59 +0200 Subject: [PATCH 06/25] Add KanbanGit MCP v0.1 --- .../ListAssignedTicketsByVisibleProject.php | 8 +- .../ListTicketsByStatusByVisibleProject.php | 67 +++ app/Mcp/Servers/KanbanGitServer.php | 37 ++ app/Mcp/Support/KanbanGitMcp.php | 239 +++++++++++ app/Mcp/Tools/GetTicket.php | 50 +++ app/Mcp/Tools/ListMyAssignedTickets.php | 56 +++ app/Mcp/Tools/ListTicketsByStatus.php | 73 ++++ app/Mcp/Tools/MoveTicketStatus.php | 99 +++++ app/Mcp/Tools/UpdateTicketAssignee.php | 68 +++ app/Mcp/Tools/UpdateTicketDescription.php | 67 +++ config/scalar.php | 6 +- docs/mcp/kanbangit-mcp-v0.1.md | 125 ++++++ routes/ai.php | 8 + .../Api/PublicApiDocumentationTest.php | 3 +- tests/Feature/Mcp/KanbanGitMcpTest.php | 389 ++++++++++++++++++ 15 files changed, 1289 insertions(+), 6 deletions(-) create mode 100644 app/Actions/Tickets/ListTicketsByStatusByVisibleProject.php create mode 100644 app/Mcp/Servers/KanbanGitServer.php create mode 100644 app/Mcp/Support/KanbanGitMcp.php create mode 100644 app/Mcp/Tools/GetTicket.php create mode 100644 app/Mcp/Tools/ListMyAssignedTickets.php create mode 100644 app/Mcp/Tools/ListTicketsByStatus.php create mode 100644 app/Mcp/Tools/MoveTicketStatus.php create mode 100644 app/Mcp/Tools/UpdateTicketAssignee.php create mode 100644 app/Mcp/Tools/UpdateTicketDescription.php create mode 100644 docs/mcp/kanbangit-mcp-v0.1.md create mode 100644 routes/ai.php create mode 100644 tests/Feature/Mcp/KanbanGitMcpTest.php diff --git a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php index f31200a..de2faf1 100644 --- a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php +++ b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php @@ -20,10 +20,16 @@ class ListAssignedTicketsByVisibleProject /** * @return array, tickets: array>}> */ - public function handle(User $viewer, User $assignee): array + public function handle(User $viewer, User $assignee, ?Project $project = null): array { /** @var EloquentCollection $visibleProjects */ $visibleProjects = $this->listVisibleProjects->handle($viewer); + if ($project !== null) { + $visibleProjects = $visibleProjects + ->where('id', $project->id) + ->values(); + } + $visibleProjectIds = $visibleProjects->pluck('id')->all(); if ($visibleProjectIds === []) { diff --git a/app/Actions/Tickets/ListTicketsByStatusByVisibleProject.php b/app/Actions/Tickets/ListTicketsByStatusByVisibleProject.php new file mode 100644 index 0000000..c8a0753 --- /dev/null +++ b/app/Actions/Tickets/ListTicketsByStatusByVisibleProject.php @@ -0,0 +1,67 @@ + $ticketStatuses + * @return array, tickets: array>}> + */ + public function handle(User $viewer, Collection $ticketStatuses, ?Project $project = null): array + { + /** @var EloquentCollection $visibleProjects */ + $visibleProjects = $this->listVisibleProjects->handle($viewer); + if ($project !== null) { + $visibleProjects = $visibleProjects + ->where('id', $project->id) + ->values(); + } + + $visibleProjectIds = $visibleProjects->pluck('id')->all(); + + if ($visibleProjectIds === [] || $ticketStatuses->isEmpty()) { + return []; + } + + $ticketStatusIds = $ticketStatuses->pluck('id')->all(); + + /** @var Collection> $ticketsByProject */ + $ticketsByProject = Ticket::query() + ->whereIn('project_id', $visibleProjectIds) + ->whereIn('ticket_status_id', $ticketStatusIds) + ->with([ + 'assignee', + 'project', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + ]) + ->orderByDesc('sequence_number') + ->get() + ->groupBy('project_id'); + + return $visibleProjects + ->filter(fn (Project $visibleProject): bool => $ticketsByProject->has($visibleProject->id)) + ->map(fn (Project $visibleProject): array => [ + 'project' => ProjectResource::make($visibleProject)->resolve(), + 'tickets' => TicketResource::collection($ticketsByProject->get($visibleProject->id))->resolve(), + ]) + ->values() + ->all(); + } +} diff --git a/app/Mcp/Servers/KanbanGitServer.php b/app/Mcp/Servers/KanbanGitServer.php new file mode 100644 index 0000000..fb5ab0f --- /dev/null +++ b/app/Mcp/Servers/KanbanGitServer.php @@ -0,0 +1,37 @@ +user(); + + if (! $user instanceof User) { + throw new AuthenticationException('Authentication required.'); + } + + return $user; + } + + /** + * @throws AuthorizationException + */ + public function ensureCanRead(User $user, Project $project): void + { + if (! $this->permissionService->allows($user, Permission::ProjectRead, $project)) { + throw new AuthorizationException('You cannot view this project.'); + } + } + + /** + * @throws AuthorizationException + */ + public function ensureCanWrite(User $user, Project $project): void + { + if (! $this->permissionService->allows($user, Permission::ProjectWrite, $project)) { + throw new AuthorizationException('You cannot update tickets in this project.'); + } + } + + /** + * @throws AuthorizationException + */ + public function ticketForRead(User $user, int $ticketId): Ticket + { + $ticket = Ticket::query() + ->with('project') + ->find($ticketId); + + if ($ticket === null) { + throw ValidationException::withMessages([ + 'ticket_id' => 'Ticket was not found.', + ]); + } + + $this->ensureCanRead($user, $ticket->project); + + return $ticket; + } + + /** + * @throws AuthorizationException + */ + public function ticketForWrite(User $user, int $ticketId): Ticket + { + $ticket = Ticket::query() + ->with('project') + ->find($ticketId); + + if ($ticket === null) { + throw ValidationException::withMessages([ + 'ticket_id' => 'Ticket was not found.', + ]); + } + + $this->ensureCanWrite($user, $ticket->project); + + return $ticket; + } + + /** + * @throws AuthorizationException + */ + public function projectForRead(User $user, ?int $projectId): ?Project + { + if ($projectId === null) { + return null; + } + + $project = Project::query()->find($projectId); + + if ($project === null) { + throw ValidationException::withMessages([ + 'project_id' => 'Project was not found.', + ]); + } + + $this->ensureCanRead($user, $project); + + return $project; + } + + /** + * @param array $validated + * @return Collection|Response + */ + public function resolveTicketStatuses(User $user, array $validated, ?Project $project = null): Collection|Response + { + $projectIds = $project === null + ? $this->permissionService->viewableProjectIds($user) + : [$project->id]; + + if ($projectIds === []) { + return collect(); + } + + $ticketStatusId = $validated['ticket_status_id'] ?? null; + if ($ticketStatusId !== null) { + $ticketStatus = TicketStatus::query() + ->whereIn('project_id', $projectIds) + ->with('boardColumn') + ->find((int) $ticketStatusId); + + return $ticketStatus === null + ? Response::error('Ticket status was not found in a visible project.') + : collect([$ticketStatus]); + } + + $query = TicketStatus::query() + ->whereIn('project_id', $projectIds) + ->with(['boardColumn', 'project']) + ->orderBy('project_id') + ->orderBy('position'); + + if (($statusSlug = $validated['status_slug'] ?? null) !== null) { + $query->where('slug', $statusSlug); + } elseif (($statusName = $validated['status_name'] ?? null) !== null) { + $query->where('name', $statusName); + } else { + return Response::error('Provide ticket_status_id, status_slug, or status_name.'); + } + + $ticketStatuses = $query->get(); + + if ($ticketStatuses->count() <= 1) { + return $ticketStatuses; + } + + $matches = $ticketStatuses + ->map(fn (TicketStatus $ticketStatus): array => [ + 'id' => $ticketStatus->id, + 'name' => $ticketStatus->name, + 'project_id' => $ticketStatus->project_id, + 'project_name' => $ticketStatus->project?->name, + 'slug' => $ticketStatus->slug, + ]) + ->values() + ->all(); + + return Response::error(sprintf( + 'Status lookup is ambiguous. Provide ticket_status_id or project_id. Matches: %s', + json_encode($matches, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + )); + } + + /** + * @return array + */ + public function ticketPayload(Ticket $ticket): array + { + $ticket->load([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'project', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + 'worklogEntries.actor', + ]); + + return [ + 'project' => ProjectResource::make($ticket->project)->resolve(), + 'ticket' => TicketResource::make($ticket)->resolve(), + ]; + } + + /** + * @return array{ + * acceptance_criteria: string|null, + * assignee_user_id: int|null, + * author_user_id: int|null, + * blocked_comment: string|null, + * description: string|null, + * is_blocked: bool, + * parent_ticket_id: int|null, + * story_points: int|null, + * ticket_priority_id: int, + * ticket_type_id: int, + * title: string + * } + */ + public function updatePayload(Ticket $ticket): array + { + return [ + 'acceptance_criteria' => $ticket->acceptance_criteria, + 'assignee_user_id' => $ticket->assignee_user_id, + 'author_user_id' => $ticket->author_user_id, + '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' => $ticket->ticket_priority_id, + 'ticket_type_id' => $ticket->ticket_type_id, + 'title' => $ticket->title, + ]; + } +} diff --git a/app/Mcp/Tools/GetTicket.php b/app/Mcp/Tools/GetTicket.php new file mode 100644 index 0000000..6949573 --- /dev/null +++ b/app/Mcp/Tools/GetTicket.php @@ -0,0 +1,50 @@ +validate([ + 'ticket_id' => ['required', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'ticket_id' => $schema->integer() + ->description('Ticket ID.') + ->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListMyAssignedTickets.php b/app/Mcp/Tools/ListMyAssignedTickets.php new file mode 100644 index 0000000..c0f2d91 --- /dev/null +++ b/app/Mcp/Tools/ListMyAssignedTickets.php @@ -0,0 +1,56 @@ +validate([ + 'project_id' => ['nullable', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $project = $this->kanbanGitMcp->projectForRead($user, $validated['project_id'] ?? null); + + return Response::structured([ + 'data' => $this->listAssignedTicketsByVisibleProject->handle($user, $user, $project), + 'meta' => [ + 'assignee' => UserResource::make($user)->resolve(), + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'project_id' => $schema->integer() + ->nullable() + ->description('Optional project ID. When omitted, tickets from all visible projects are returned.'), + ]; + } +} diff --git a/app/Mcp/Tools/ListTicketsByStatus.php b/app/Mcp/Tools/ListTicketsByStatus.php new file mode 100644 index 0000000..581ca6c --- /dev/null +++ b/app/Mcp/Tools/ListTicketsByStatus.php @@ -0,0 +1,73 @@ +validate([ + 'project_id' => ['nullable', 'integer'], + 'status_name' => ['nullable', 'string'], + 'status_slug' => ['nullable', 'string'], + 'ticket_status_id' => ['nullable', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $project = $this->kanbanGitMcp->projectForRead($user, $validated['project_id'] ?? null); + $ticketStatuses = $this->kanbanGitMcp->resolveTicketStatuses($user, $validated, $project); + + if ($ticketStatuses instanceof Response) { + return $ticketStatuses; + } + + return Response::structured([ + 'data' => $this->listTicketsByStatusByVisibleProject->handle($user, $ticketStatuses, $project), + 'meta' => [ + 'statuses' => TicketStatusResource::collection($ticketStatuses)->resolve(), + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'project_id' => $schema->integer() + ->nullable() + ->description('Optional project ID. Use it to scope status_name or status_slug and avoid ambiguity.'), + 'status_name' => $schema->string() + ->nullable() + ->description('Exact ticket status name. Must match exactly and be unambiguous.'), + 'status_slug' => $schema->string() + ->nullable() + ->description('Ticket status slug. Must be unambiguous unless project_id is provided.'), + 'ticket_status_id' => $schema->integer() + ->nullable() + ->description('Stable ticket status ID. Preferred when available.'), + ]; + } +} diff --git a/app/Mcp/Tools/MoveTicketStatus.php b/app/Mcp/Tools/MoveTicketStatus.php new file mode 100644 index 0000000..b3b2d7e --- /dev/null +++ b/app/Mcp/Tools/MoveTicketStatus.php @@ -0,0 +1,99 @@ +validate([ + 'status_name' => ['nullable', 'string'], + 'status_slug' => ['nullable', 'string'], + 'ticket_id' => ['required', 'integer'], + 'ticket_status_id' => ['nullable', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); + $ticketStatuses = $this->kanbanGitMcp->resolveTicketStatuses($user, $validated, $ticket->project); + + if ($ticketStatuses instanceof Response) { + return $ticketStatuses; + } + + if ($ticketStatuses->isEmpty()) { + return Response::error('Ticket status was not found in this ticket project.'); + } + + $destinationStatus = $ticketStatuses->first(); + + if ($destinationStatus->project_id !== $ticket->project_id) { + return Response::error('Ticket status must belong to the same project as the ticket.'); + } + + $ticket->loadMissing('ticketStatus'); + + if ( + $ticket->ticket_status_id !== $destinationStatus->id + && ! ($ticket->ticketStatus?->allowedTargetStatuses()->whereKey($destinationStatus->id)->exists() ?? false) + ) { + return Response::error('Choose a status this ticket is allowed to move to.'); + } + + $result = $this->moveProjectTicket->handle($ticket->project, $ticket, $user, [ + 'ticket_status_id' => $destinationStatus->id, + ]); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()), + 'meta' => [ + 'destination_label' => $result['destination_label'], + 'message' => $result['moved'] + ? "{$ticket->ticket_key} moved to {$result['destination_label']}." + : "{$ticket->ticket_key} already belongs to {$result['destination_label']}.", + 'moved' => $result['moved'], + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'status_name' => $schema->string() + ->nullable() + ->description('Exact target status name. Must resolve to one status in the ticket project.'), + 'status_slug' => $schema->string() + ->nullable() + ->description('Target status slug. Must resolve to one status in the ticket project.'), + 'ticket_id' => $schema->integer() + ->description('Ticket ID.') + ->required(), + 'ticket_status_id' => $schema->integer() + ->nullable() + ->description('Stable target status ID. Preferred when available.'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateTicketAssignee.php b/app/Mcp/Tools/UpdateTicketAssignee.php new file mode 100644 index 0000000..2215ddc --- /dev/null +++ b/app/Mcp/Tools/UpdateTicketAssignee.php @@ -0,0 +1,68 @@ +validate([ + 'assignee_user_id' => ['present', 'nullable', 'integer', 'exists:users,id'], + 'ticket_id' => ['required', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); + $payload = [ + ...$this->kanbanGitMcp->updatePayload($ticket), + 'assignee_user_id' => $validated['assignee_user_id'] ?? null, + ]; + + $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()), + 'meta' => [ + 'message' => $wasUpdated + ? "{$ticket->ticket_key} assignee was updated." + : 'No ticket changes were detected.', + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'assignee_user_id' => $schema->integer() + ->nullable() + ->description('User ID to assign. Pass null to unassign.') + ->required(), + 'ticket_id' => $schema->integer() + ->description('Ticket ID.') + ->required(), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateTicketDescription.php b/app/Mcp/Tools/UpdateTicketDescription.php new file mode 100644 index 0000000..fc816bf --- /dev/null +++ b/app/Mcp/Tools/UpdateTicketDescription.php @@ -0,0 +1,67 @@ +validate([ + 'description' => ['nullable', 'string'], + 'ticket_id' => ['required', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); + $payload = [ + ...$this->kanbanGitMcp->updatePayload($ticket), + 'description' => $validated['description'] ?? null, + ]; + + $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()), + 'meta' => [ + 'message' => $wasUpdated + ? "{$ticket->ticket_key} description was updated." + : 'No ticket changes were detected.', + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'description' => $schema->string() + ->nullable() + ->description('New ticket description. Pass null to clear it.'), + 'ticket_id' => $schema->integer() + ->description('Ticket ID.') + ->required(), + ]; + } +} diff --git a/config/scalar.php b/config/scalar.php index e750dee..d0a2db5 100644 --- a/config/scalar.php +++ b/config/scalar.php @@ -117,11 +117,9 @@ return [ */ 'hideClientButton' => true, - /** - * MCP will be introduced as a first-party KanbanGit feature later. - */ 'mcp' => [ - 'disabled' => true, + 'name' => 'KanbanGit MCP', + 'url' => '/mcp/kanbangit', ], /** diff --git a/docs/mcp/kanbangit-mcp-v0.1.md b/docs/mcp/kanbangit-mcp-v0.1.md new file mode 100644 index 0000000..ce70799 --- /dev/null +++ b/docs/mcp/kanbangit-mcp-v0.1.md @@ -0,0 +1,125 @@ +# KanbanGit MCP v0.1 + +KanbanGit exposes a first-party HTTP MCP server for AI tools that need to read and update tickets without interpreting the OpenAPI document directly. + +## Endpoint + +- URL: `/mcp/kanbangit` +- Transport: HTTP MCP +- Authentication: `Authorization: Bearer ` + +Application keys are created in Settings > Applications. MCP requests act with the full permissions of the key owner, so project visibility and write permissions match the web UI and JSON API. + +## Implemented Tools + +### `list_my_assigned_tickets` + +Lists tickets assigned to the authenticated application key owner, grouped by visible project. + +Inputs: + +- `project_id` optional integer. When omitted, all visible projects are included. + +Output: + +- `data`: project groups with `project` and `tickets` +- `meta.assignee`: authenticated user + +### `list_tickets_by_status` + +Lists tickets in a target status, grouped by visible project. + +Inputs: + +- `ticket_status_id` optional integer, preferred stable lookup +- `status_slug` optional string +- `status_name` optional string +- `project_id` optional integer to scope the lookup + +If `status_slug` or `status_name` matches more than one visible status, the tool returns an MCP error with matching status IDs so the client can retry with `ticket_status_id` or `project_id`. + +Output: + +- `data`: project groups with `project` and `tickets` +- `meta.statuses`: statuses used for the lookup + +### `get_ticket` + +Returns a visible ticket with its project, status, type, priority, assignee, author, field values, and worklog data. + +Inputs: + +- `ticket_id` required integer + +Output: + +- `data.project` +- `data.ticket` + +### `update_ticket_description` + +Updates only the ticket description and preserves all other ticket fields. + +Inputs: + +- `ticket_id` required integer +- `description` nullable string + +Requires project write permission. + +### `update_ticket_assignee` + +Assigns or unassigns a ticket and preserves all other ticket fields. + +Inputs: + +- `ticket_id` required integer +- `assignee_user_id` required nullable integer. Use `null` to unassign. + +Requires project write permission. + +### `move_ticket_status` + +Moves a ticket to an allowed target status. + +Inputs: + +- `ticket_id` required integer +- `ticket_status_id` optional integer, preferred stable lookup +- `status_slug` optional string +- `status_name` optional string + +The destination status must belong to the ticket project. When moving to a different status, the transition must be allowed by the ticket's current status. + +Output: + +- `data.project` +- `data.ticket` +- `meta.destination_label` +- `meta.message` +- `meta.moved` + +## OpenClaw Setup Notes + +Configure OpenClaw as an HTTP MCP client that points to your KanbanGit host plus `/mcp/kanbangit`. + +Send the application key as: + +```text +Authorization: Bearer +``` + +Use `list_my_assigned_tickets` for the dashboard-style "my work" view. Use `get_ticket` before updates when the bot needs current ticket context. Prefer IDs over names when moving statuses. + +## Noch fehlt + +- Ticket creation +- Branch tools +- Ticket links +- Comments and richer worklog tools +- Repository tools +- Project settings tools +- OAuth-based MCP auth +- Scoped or expiring MCP tokens +- Local stdio MCP server +- MCP prompts and resources diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 0000000..f950a2f --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,8 @@ +middleware(AuthenticateApiApplicationKey::class); diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php index 4886e24..90821ef 100644 --- a/tests/Feature/Api/PublicApiDocumentationTest.php +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -11,7 +11,8 @@ class PublicApiDocumentationTest extends TestCase $this->get('/api/docs') ->assertOk() ->assertSee('"hideClientButton":true', false) - ->assertSee('"mcp":{"disabled":true}', false); + ->assertSee('"hideTestRequestButton":true', false) + ->assertSee('"mcp":{"name":"KanbanGit MCP","url":"\/mcp\/kanbangit"}', false); } public function test_openapi_document_is_publicly_served(): void diff --git a/tests/Feature/Mcp/KanbanGitMcpTest.php b/tests/Feature/Mcp/KanbanGitMcpTest.php new file mode 100644 index 0000000..75517d4 --- /dev/null +++ b/tests/Feature/Mcp/KanbanGitMcpTest.php @@ -0,0 +1,389 @@ +postJson('/mcp/kanbangit', [ + 'id' => 1, + 'jsonrpc' => '2.0', + 'method' => 'tools/list', + 'params' => [], + ])->assertUnauthorized(); + } + + public function test_valid_application_key_can_list_tools_over_http(): void + { + $user = User::factory()->create(); + + $this->withApplicationKey($user) + ->postJson('/mcp/kanbangit', [ + 'id' => 1, + 'jsonrpc' => '2.0', + 'method' => 'tools/list', + 'params' => [], + ]) + ->assertOk() + ->assertJsonPath('result.tools.0.name', 'list_my_assigned_tickets') + ->assertJsonPath('result.tools.5.name', 'move_ticket_status'); + } + + public function test_list_my_assigned_tickets_returns_only_visible_current_user_tickets(): void + { + $user = User::factory()->create([ + 'name' => 'MCP User', + ]); + $otherUser = User::factory()->create(); + $visibleProject = $this->project('CORE', 'Visible project'); + $hiddenProject = $this->project('HIDE', 'Hidden project'); + $visibleStatus = $visibleProject->ticketStatuses()->firstOrFail(); + $hiddenStatus = $hiddenProject->ticketStatuses()->firstOrFail(); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + + $this->ticket($visibleProject, $visibleStatus, [ + 'assignee_user_id' => $user->id, + 'sequence_number' => 2, + 'ticket_key' => 'CORE-2', + 'title' => 'Visible assigned ticket', + ]); + $this->ticket($visibleProject, $visibleStatus, [ + 'assignee_user_id' => $otherUser->id, + 'sequence_number' => 3, + 'ticket_key' => 'CORE-3', + 'title' => 'Other user ticket', + ]); + $this->ticket($hiddenProject, $hiddenStatus, [ + 'assignee_user_id' => $user->id, + 'sequence_number' => 1, + 'ticket_key' => 'HIDE-1', + 'title' => 'Hidden assigned ticket', + ]); + + KanbanGitServer::actingAs($user) + ->tool(ListMyAssignedTickets::class) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('meta.assignee.name', 'MCP User') + ->where('data.0.project.name', 'Visible project') + ->where('data.0.tickets.0.ticket_key', 'CORE-2') + ->count('data.0.tickets', 1) + ->count('data', 1) + ->etc()); + } + + public function test_list_tickets_by_status_works_globally_and_with_project_filter(): void + { + $user = User::factory()->create(); + $visibleProject = $this->project('CORE', 'Core project'); + $otherVisibleProject = $this->project('OPS', 'Ops project'); + $visibleStatus = $this->ticketStatus($visibleProject, 'Ready'); + $otherStatus = $this->ticketStatus($otherVisibleProject, 'Ready'); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + $this->syncProjectPermissions($otherVisibleProject, $user, ['view']); + + $this->ticket($visibleProject, $visibleStatus, [ + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'title' => 'Core ready ticket', + ]); + $this->ticket($otherVisibleProject, $otherStatus, [ + 'sequence_number' => 1, + 'ticket_key' => 'OPS-1', + 'title' => 'Ops ready ticket', + ]); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'ticket_status_id' => $visibleStatus->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('meta.statuses.0.id', $visibleStatus->id) + ->where('data.0.project.name', 'Core project') + ->where('data.0.tickets.0.ticket_key', 'CORE-1') + ->count('data', 1) + ->etc()); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'project_id' => $otherVisibleProject->id, + 'status_name' => 'Ready', + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('meta.statuses.0.id', $otherStatus->id) + ->where('data.0.project.name', 'Ops project') + ->where('data.0.tickets.0.ticket_key', 'OPS-1') + ->count('data', 1) + ->etc()); + } + + public function test_status_ticket_lists_exclude_invisible_projects(): void + { + $user = User::factory()->create(); + $visibleProject = $this->project('CORE', 'Visible project'); + $hiddenProject = $this->project('HIDE', 'Hidden project'); + $visibleStatus = $this->ticketStatus($visibleProject, 'Visible Review'); + $hiddenStatus = $this->ticketStatus($hiddenProject, 'Hidden Review'); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + + $this->ticket($visibleProject, $visibleStatus, [ + 'ticket_key' => 'CORE-1', + 'title' => 'Visible status ticket', + ]); + $this->ticket($hiddenProject, $hiddenStatus, [ + 'ticket_key' => 'HIDE-1', + 'title' => 'Hidden status ticket', + ]); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'ticket_status_id' => $visibleStatus->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.0.tickets.0.ticket_key', 'CORE-1') + ->count('data', 1) + ->etc()); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'ticket_status_id' => $hiddenStatus->id, + ]) + ->assertHasErrors(['Ticket status was not found in a visible project.']); + } + + public function test_get_ticket_returns_visible_ticket_detail(): void + { + $user = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $status = $project->ticketStatuses()->firstOrFail(); + $ticket = $this->ticket($project, $status, [ + 'description' => 'Detailed MCP description', + 'ticket_key' => 'CORE-1', + 'title' => 'Detailed ticket', + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(GetTicket::class, [ + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.project.name', 'Core project') + ->where('data.ticket.ticket_key', 'CORE-1') + ->where('data.ticket.description', 'Detailed MCP description') + ->etc()); + } + + public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void + { + $user = User::factory()->create(); + $assignee = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $status = $project->ticketStatuses()->firstOrFail(); + $ticket = $this->ticket($project, $status, [ + 'assignee_user_id' => $assignee->id, + 'description' => 'Old description', + 'story_points' => 8, + 'ticket_key' => 'CORE-1', + 'title' => 'Description ticket', + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(UpdateTicketDescription::class, [ + 'description' => 'New description', + 'ticket_id' => $ticket->id, + ]) + ->assertHasErrors(['cannot update tickets']); + + $this->syncProjectPermissions($project, $user, ['write']); + + KanbanGitServer::actingAs($user) + ->tool(UpdateTicketDescription::class, [ + 'description' => 'New description', + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.description', 'New description') + ->where('data.ticket.assignee_user_id', $assignee->id) + ->where('data.ticket.story_points', 8) + ->etc()); + } + + public function test_assignee_update_supports_assigning_and_unassigning(): void + { + $user = User::factory()->create(); + $assignee = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $status = $project->ticketStatuses()->firstOrFail(); + $ticket = $this->ticket($project, $status, [ + 'assignee_user_id' => null, + 'ticket_key' => 'CORE-1', + 'title' => 'Assignee ticket', + ]); + + $this->syncProjectPermissions($project, $user, ['write']); + + KanbanGitServer::actingAs($user) + ->tool(UpdateTicketAssignee::class, [ + 'assignee_user_id' => $assignee->id, + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.assignee_user_id', $assignee->id) + ->etc()); + + KanbanGitServer::actingAs($user) + ->tool(UpdateTicketAssignee::class, [ + 'assignee_user_id' => null, + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.assignee_user_id', null) + ->etc()); + } + + public function test_status_move_requires_write_permission_and_allowed_transition(): void + { + $user = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $backlogStatus = $project->ticketStatuses()->firstOrFail(); + $doingStatus = $this->ticketStatus($project, 'Doing'); + $blockedStatus = $this->ticketStatus($project, 'Blocked'); + $ticket = $this->ticket($project, $backlogStatus, [ + 'ticket_key' => 'CORE-1', + 'title' => 'Move ticket', + ]); + + $backlogStatus->allowedTargetStatuses()->sync([$doingStatus->id]); + $this->syncProjectPermissions($project, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(MoveTicketStatus::class, [ + 'ticket_id' => $ticket->id, + 'ticket_status_id' => $doingStatus->id, + ]) + ->assertHasErrors(['cannot update tickets']); + + $this->syncProjectPermissions($project, $user, ['write']); + + KanbanGitServer::actingAs($user) + ->tool(MoveTicketStatus::class, [ + 'ticket_id' => $ticket->id, + 'ticket_status_id' => $blockedStatus->id, + ]) + ->assertHasErrors(['allowed to move']); + + KanbanGitServer::actingAs($user) + ->tool(MoveTicketStatus::class, [ + 'status_slug' => 'doing', + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.ticket_status_id', $doingStatus->id) + ->where('meta.moved', true) + ->where('meta.destination_label', 'Doing') + ->etc()); + } + + public function test_ambiguous_status_name_returns_mcp_error(): void + { + $user = User::factory()->create(); + $firstProject = $this->project('ONE', 'First project'); + $secondProject = $this->project('TWO', 'Second project'); + $this->ticketStatus($firstProject, 'Review'); + $this->ticketStatus($secondProject, 'Review'); + + $this->syncProjectPermissions($firstProject, $user, ['view']); + $this->syncProjectPermissions($secondProject, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'status_name' => 'Review', + ]) + ->assertHasErrors(['Status lookup is ambiguous']) + ->assertSee(['First project', 'Second project']); + } + + private function project(string $key, string $name): Project + { + $project = Project::factory()->create([ + 'key' => $key, + 'name' => $name, + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + return $project; + } + + private function ticketStatus(Project $project, string $name): TicketStatus + { + $position = $project->boardColumns()->count() + 1; + $boardColumn = BoardColumn::factory()->for($project)->create([ + 'name' => $name, + 'position' => $position, + ]); + + return TicketStatus::factory() + ->for($project) + ->for($boardColumn) + ->create([ + 'name' => $name, + 'position' => $position, + ]); + } + + /** + * @param array $attributes + */ + private function ticket(Project $project, TicketStatus $ticketStatus, array $attributes = []): Ticket + { + $priority = $project->ticketPriorities()->firstOrFail(); + $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); + + return Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => "{$project->key}-1", + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $ticketStatus->id, + 'ticket_type_id' => $type->id, + 'title' => 'MCP ticket', + ...$attributes, + ]); + } +} From 8df9622f1be7c1adf6788c93406741de913eb081 Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 21:03:02 +0200 Subject: [PATCH 07/25] Add v0.5 webhook notification release tickets --- docs/releases/README.md | 2 + docs/releases/ticket-implementation-guide.md | 48 +++++++++++++ .../01-notification-action-foundation.md | 62 ++++++++++++++++ .../02-project-notification-settings-ui.md | 69 ++++++++++++++++++ .../03-ticket-status-changed-trigger.md | 67 ++++++++++++++++++ .../04-generic-webhook-delivery.md | 70 +++++++++++++++++++ .../05-discord-webhook-delivery.md | 69 ++++++++++++++++++ ...otification-tests-and-release-hardening.md | 62 ++++++++++++++++ .../v0.5-webhook-notification/README.md | 35 ++++++++++ 9 files changed, 484 insertions(+) create mode 100644 docs/releases/ticket-implementation-guide.md create mode 100644 docs/releases/v0.5-webhook-notification/01-notification-action-foundation.md create mode 100644 docs/releases/v0.5-webhook-notification/02-project-notification-settings-ui.md create mode 100644 docs/releases/v0.5-webhook-notification/03-ticket-status-changed-trigger.md create mode 100644 docs/releases/v0.5-webhook-notification/04-generic-webhook-delivery.md create mode 100644 docs/releases/v0.5-webhook-notification/05-discord-webhook-delivery.md create mode 100644 docs/releases/v0.5-webhook-notification/06-notification-tests-and-release-hardening.md create mode 100644 docs/releases/v0.5-webhook-notification/README.md diff --git a/docs/releases/README.md b/docs/releases/README.md index 55af36d..83d767f 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -7,10 +7,12 @@ This directory contains small, ordered implementation tickets grouped by release - [Release 0.1 - Kanban, Ticketing, Users, and Permissions Foundation](0.1-kanban-foundation/01-admin-and-user-management-foundation.md) - [Release 0.2 - Gitea Integration](0.2-gitea-integration/01-git-provider-abstraction-and-gitea-foundation.md) - [Release 0.3 - GitHub Integration](0.3-github-integration/README.md) +- [Release v0.5 - Webhook Notifications](v0.5-webhook-notification/README.md) ## Scope notes - Release `0.1` covers the core Kanban, ticket, user, team, and permission foundation. - Release `0.2` covers the first Git provider integration with Gitea. - Release `0.3` covers GitHub Cloud as the second Git provider using the existing provider abstraction and user connection model. +- Release `v0.5` covers project-scoped webhook notification actions for ticket status changes. - Roadmap and other currently out-of-scope items are intentionally not broken into tickets here. diff --git a/docs/releases/ticket-implementation-guide.md b/docs/releases/ticket-implementation-guide.md new file mode 100644 index 0000000..8682ccf --- /dev/null +++ b/docs/releases/ticket-implementation-guide.md @@ -0,0 +1,48 @@ +# Ticket Implementation Guide + +This guide describes how to implement release tickets in this repository. + +## Branching + +- Use exactly one branch per ticket. +- Use the branch format `codex//`. +- For release `v0.5-webhook-notification`, branch names must look like `codex/v0.5-webhook-notification/01-notification-action-foundation`. +- The first ticket branch starts from the release base branch chosen by the maintainer. +- Every later ticket branch starts from the previous ticket branch: + - ticket 02 branches from ticket 01 + - ticket 03 branches from ticket 02 + - ticket 04 branches from ticket 03 + - continue this pattern until the release is complete +- Do not mix multiple release tickets in one branch. + +## Implementation Flow + +- Read the ticket completely before changing files. +- Keep the implementation limited to the ticket scope. +- Preserve existing UI, API, and MCP behavior unless the ticket explicitly changes it. +- Follow existing project conventions before introducing new patterns. +- Use actions or services for reusable business logic. +- Use FormRequest classes for request validation. +- Keep app-specific frontend types under `resources/js/types`; do not define them inside `.vue` files. +- Generate Wayfinder output when frontend-facing routes or controllers change. + +## Finishing A Ticket + +Each ticket must end with: + +- Relevant automated tests for the changed behavior. +- Formatting and code generation required by the files changed. +- `git diff --check`. +- `git add` for only the ticket's intended files. +- One commit with a concise ticket-specific message. +- `git push origin `. + +Do not leave partially staged work for the next ticket. The next ticket should start from the pushed branch of the completed ticket. + +## Verification Defaults + +- PHP changes: run `vendor/bin/pint --dirty --format agent`. +- Laravel behavior changes: run targeted `php artisan test --compact ...`. +- Route or frontend controller changes: run `php artisan wayfinder:generate --with-form --no-interaction`. +- Vue or TypeScript changes: run `npm run types:check`. +- Documentation-only changes: `git diff --check` is enough unless the ticket says otherwise. diff --git a/docs/releases/v0.5-webhook-notification/01-notification-action-foundation.md b/docs/releases/v0.5-webhook-notification/01-notification-action-foundation.md new file mode 100644 index 0000000..7fe2a0b --- /dev/null +++ b/docs/releases/v0.5-webhook-notification/01-notification-action-foundation.md @@ -0,0 +1,62 @@ +# 01 Notification Action Foundation + +Implementation guide: [Ticket Implementation Guide](../ticket-implementation-guide.md) + +## Branch + +`codex/v0.5-webhook-notification/01-notification-action-foundation` + +Start this branch from the chosen release base branch. + +## Goal + +Create the backend foundation for project-scoped notification actions and delivery logs. + +## Requirements + +- Add database tables for configured notification actions and delivery attempts. +- Add models, factories, resources, enums, and reusable actions or services for notification action management. +- A notification action belongs to a project. +- A notification action has a name, enabled flag, event type, channel, channel configuration, optional message template, and secret delivery URL. +- Supported event type for this release: `ticket_status_changed`. +- Supported channels for this release: `webhook` and `discord`. +- Store webhook and Discord URLs encrypted and hide them from serialized model/resource output. +- Add delivery logs that can record action, event type, channel, delivery status, attempt count, HTTP status, response or error excerpt, payload metadata, and timestamps. +- Keep the schema extensible for later event types and channels. + +## Acceptance Criteria + +- Notification action records can be created, updated, listed, and deleted through backend actions or services. +- Secret URLs are encrypted at rest and are not exposed by resources. +- Delivery log records can represent pending, succeeded, failed, and retryable attempts. +- The foundation does not dispatch external HTTP requests yet. +- Existing ticket, project, API, and MCP behavior is unchanged. + +## Tests + +- Model tests for relationships, casts, enum handling, and hidden secret URL output. +- Factory tests or feature-backed assertions proving webhook and Discord action records can be created. +- Action/service tests for create, update, delete, and project ownership behavior. +- Delivery log tests for supported statuses and relationship integrity. + +## Verification + +- `vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Feature/Models tests/Feature/Notifications` +- `git diff --check` + +## Commit And Push + +- Commit only this ticket's changes. +- Push with `git push origin codex/v0.5-webhook-notification/01-notification-action-foundation`. + +## Out of Scope + +- Project settings UI +- Ticket status changed event dispatch +- Actual webhook or Discord HTTP delivery +- User bell notifications +- Unread counters +- Slack, Teams, email, or other channels +- Incoming webhooks +- API or MCP management endpoints diff --git a/docs/releases/v0.5-webhook-notification/02-project-notification-settings-ui.md b/docs/releases/v0.5-webhook-notification/02-project-notification-settings-ui.md new file mode 100644 index 0000000..1011c0a --- /dev/null +++ b/docs/releases/v0.5-webhook-notification/02-project-notification-settings-ui.md @@ -0,0 +1,69 @@ +# 02 Project Notification Settings UI + +Implementation guide: [Ticket Implementation Guide](../ticket-implementation-guide.md) + +## Branch + +`codex/v0.5-webhook-notification/02-project-notification-settings-ui` + +Start this branch from `codex/v0.5-webhook-notification/01-notification-action-foundation`. + +## Goal + +Add project settings UI for managing notification actions. + +## Requirements + +- Add a project settings page at `projects/{project}/settings/notifications`. +- Add a settings navigation link alongside general, board, ticket, and repository settings. +- Project admins can list, create, update, enable, disable, and delete project notification actions. +- The create and update forms support: + - name + - event type + - channel + - enabled flag + - delivery URL + - message template for Discord +- Existing secret URLs stay hidden after save. +- Updating an action only replaces the secret URL when a new URL is submitted. +- Show the fixed template variables in the UI: `$url`, `$ticket_key`, `$ticket_title`, `$project_name`, `$status_from`, `$status_to`, and `$actor_name`. +- Add frontend types under `resources/js/types`. +- Generate Wayfinder output for the new routes and controller methods. + +## Acceptance Criteria + +- Project admins can manage notification actions from project settings. +- Non-admin project users cannot access the notification settings page or mutate notification actions. +- Validation errors are shown next to the relevant form fields. +- Saved notification actions show their name, event type, channel, enabled state, and created or updated date without exposing the secret URL. +- Existing project settings pages continue to work. + +## Tests + +- Feature tests for page access, create, update, enable/disable, delete, validation, and authorization. +- Inertia prop assertions for the notification action list and template variable metadata. +- Regression test that secret URLs are not rendered back in page props. + +## Verification + +- `php artisan wayfinder:generate --with-form --no-interaction` +- `vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Feature/Projects` +- `npm run types:check` +- `git diff --check` + +## Commit And Push + +- Commit only this ticket's changes. +- Push with `git push origin codex/v0.5-webhook-notification/02-project-notification-settings-ui`. + +## Out of Scope + +- Ticket status changed event dispatch +- Actual webhook or Discord HTTP delivery +- Delivery attempt screens +- User bell notifications +- Unread counters +- Slack, Teams, email, or other channels +- Incoming webhooks +- API or MCP management endpoints diff --git a/docs/releases/v0.5-webhook-notification/03-ticket-status-changed-trigger.md b/docs/releases/v0.5-webhook-notification/03-ticket-status-changed-trigger.md new file mode 100644 index 0000000..9afab29 --- /dev/null +++ b/docs/releases/v0.5-webhook-notification/03-ticket-status-changed-trigger.md @@ -0,0 +1,67 @@ +# 03 Ticket Status Changed Trigger + +Implementation guide: [Ticket Implementation Guide](../ticket-implementation-guide.md) + +## Branch + +`codex/v0.5-webhook-notification/03-ticket-status-changed-trigger` + +Start this branch from `codex/v0.5-webhook-notification/02-project-notification-settings-ui`. + +## Goal + +Trigger notification deliveries when a ticket status actually changes. + +## Requirements + +- Add a `TicketStatusChanged` event or equivalent internal event object. +- Emit the event from the existing ticket move action only when the ticket moves to a different status. +- Do not emit the event for no-op moves where the destination status is already current. +- Build a reusable event payload that includes: + - project id, key, and name + - ticket id, key, title, and absolute ticket URL + - actor id and name when available + - origin status id and label + - destination status id and label + - occurred timestamp +- Add a listener or action that finds enabled project notification actions for `ticket_status_changed`. +- Queue one delivery job per matching action. +- Keep existing worklog behavior for status changes. + +## Acceptance Criteria + +- Moving a ticket to a new status queues delivery jobs for all enabled matching actions in the ticket project. +- Disabled actions are ignored. +- Actions from other projects are ignored. +- No-op moves do not queue delivery jobs. +- Ticket movement responses and worklog entries remain unchanged. + +## Tests + +- Ticket move feature tests asserting jobs are queued for enabled matching actions. +- Tests asserting disabled, foreign-project, and wrong-event actions are ignored. +- Test asserting no-op moves do not dispatch notifications. +- Test asserting the event payload contains the expected ticket URL and status labels. + +## Verification + +- `vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Feature/Projects tests/Feature/Notifications` +- `php artisan test --compact tests/Feature/Api/TicketApiTest.php tests/Feature/Mcp` +- `git diff --check` + +## Commit And Push + +- Commit only this ticket's changes. +- Push with `git push origin codex/v0.5-webhook-notification/03-ticket-status-changed-trigger`. + +## Out of Scope + +- Actual webhook HTTP request execution +- Actual Discord HTTP request execution +- Delivery attempt UI +- User bell notifications +- Unread counters +- Slack, Teams, email, or other channels +- Incoming webhooks +- API or MCP management endpoints diff --git a/docs/releases/v0.5-webhook-notification/04-generic-webhook-delivery.md b/docs/releases/v0.5-webhook-notification/04-generic-webhook-delivery.md new file mode 100644 index 0000000..a5f35dd --- /dev/null +++ b/docs/releases/v0.5-webhook-notification/04-generic-webhook-delivery.md @@ -0,0 +1,70 @@ +# 04 Generic Webhook Delivery + +Implementation guide: [Ticket Implementation Guide](../ticket-implementation-guide.md) + +## Branch + +`codex/v0.5-webhook-notification/04-generic-webhook-delivery` + +Start this branch from `codex/v0.5-webhook-notification/03-ticket-status-changed-trigger`. + +## Goal + +Deliver `ticket_status_changed` notifications to classic JSON webhook URLs. + +## Requirements + +- Implement queued delivery for notification actions using the `webhook` channel. +- Send an HTTP `POST` JSON request to the configured webhook URL. +- Include a structured payload suitable for n8n: + - event type + - project object + - ticket object + - actor object or null + - from status object + - to status object + - ticket URL + - occurred timestamp +- Use Laravel's HTTP client with a sensible timeout. +- Configure job retry/backoff so temporary webhook failures can retry. +- Persist delivery logs for success and failure. +- Store HTTP status and a short response or error excerpt when available. +- External delivery failures must not roll back ticket moves. + +## Acceptance Criteria + +- Webhook actions receive a JSON `POST` for ticket status changes. +- The payload contains enough context for n8n to route and format follow-up automation. +- Successful responses mark the delivery log as succeeded. +- Failed responses or connection errors mark the delivery attempt as failed or retryable according to the job state. +- Discord actions are not delivered by this ticket. + +## Tests + +- HTTP fake tests asserting method, URL, headers, and JSON payload. +- Tests for successful delivery log updates. +- Tests for non-2xx responses and connection failures. +- Queue/job tests for retry/backoff configuration where practical. +- Regression test that ticket moves still succeed when webhook delivery fails later. + +## Verification + +- `vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Feature/Notifications tests/Feature/Projects` +- `git diff --check` + +## Commit And Push + +- Commit only this ticket's changes. +- Push with `git push origin codex/v0.5-webhook-notification/04-generic-webhook-delivery`. + +## Out of Scope + +- Discord delivery +- Custom webhook headers or signing secrets +- Delivery attempt UI +- User bell notifications +- Unread counters +- Slack, Teams, email, or other channels +- Incoming webhooks +- API or MCP management endpoints diff --git a/docs/releases/v0.5-webhook-notification/05-discord-webhook-delivery.md b/docs/releases/v0.5-webhook-notification/05-discord-webhook-delivery.md new file mode 100644 index 0000000..8a7cb8b --- /dev/null +++ b/docs/releases/v0.5-webhook-notification/05-discord-webhook-delivery.md @@ -0,0 +1,69 @@ +# 05 Discord Webhook Delivery + +Implementation guide: [Ticket Implementation Guide](../ticket-implementation-guide.md) + +## Branch + +`codex/v0.5-webhook-notification/05-discord-webhook-delivery` + +Start this branch from `codex/v0.5-webhook-notification/04-generic-webhook-delivery`. + +## Goal + +Deliver `ticket_status_changed` notifications to Discord webhooks with configurable message text. + +## Requirements + +- Implement queued delivery for notification actions using the `discord` channel. +- Send an HTTP `POST` request to the configured Discord webhook URL. +- Render the configured message template before delivery. +- Supported template variables: + - `$url` + - `$ticket_key` + - `$ticket_title` + - `$project_name` + - `$status_from` + - `$status_to` + - `$actor_name` +- Send Discord payload `{ "content": "" }`. +- Validate or guard that rendered Discord messages are non-empty. +- Respect Discord content length limits. +- Persist delivery logs using the same delivery logging rules as the generic webhook channel. + +## Acceptance Criteria + +- Discord actions receive a rendered text message for ticket status changes. +- Template variables are replaced with event payload values. +- Unknown template text remains literal text. +- Empty rendered messages are rejected before delivery and logged as failed. +- Generic webhook actions continue to deliver their JSON payload unchanged. + +## Tests + +- Template rendering tests for every supported variable. +- HTTP fake tests asserting Discord payload shape. +- Tests for empty template output and content length validation. +- Delivery log tests for successful and failed Discord requests. +- Regression tests proving webhook channel behavior remains unchanged. + +## Verification + +- `vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Feature/Notifications` +- `git diff --check` + +## Commit And Push + +- Commit only this ticket's changes. +- Push with `git push origin codex/v0.5-webhook-notification/05-discord-webhook-delivery`. + +## Out of Scope + +- Discord embeds or rich formatting +- Discord bot tokens +- Slack, Teams, email, or other channels +- Delivery attempt UI +- User bell notifications +- Unread counters +- Incoming webhooks +- API or MCP management endpoints diff --git a/docs/releases/v0.5-webhook-notification/06-notification-tests-and-release-hardening.md b/docs/releases/v0.5-webhook-notification/06-notification-tests-and-release-hardening.md new file mode 100644 index 0000000..c9346e7 --- /dev/null +++ b/docs/releases/v0.5-webhook-notification/06-notification-tests-and-release-hardening.md @@ -0,0 +1,62 @@ +# 06 Notification Tests and Release Hardening + +Implementation guide: [Ticket Implementation Guide](../ticket-implementation-guide.md) + +## Branch + +`codex/v0.5-webhook-notification/06-notification-tests-and-release-hardening` + +Start this branch from `codex/v0.5-webhook-notification/05-discord-webhook-delivery`. + +## Goal + +Complete release-level coverage and polish for v0.5 webhook notifications. + +## Requirements + +- Review the full v0.5 implementation against the release README and all prior tickets. +- Fill missing tests for permissions, validation, hidden secrets, queued delivery, delivery logs, disabled actions, no-op moves, and multiple actions per event. +- Confirm project settings navigation is consistent across settings pages. +- Confirm existing ticket move flows still behave the same for web, API, and MCP clients. +- Confirm documentation matches the implemented behavior. +- Update release ticket docs only when they differ from the finished implementation. + +## Acceptance Criteria + +- All v0.5 acceptance criteria are covered by automated tests. +- A ticket status change can trigger multiple enabled actions for the same project. +- Failed external deliveries do not prevent ticket status movement. +- Secrets are never exposed in resources, Inertia props, logs, or tests. +- Existing web, API, and MCP status movement tests pass. +- The release docs are accurate after implementation. + +## Tests + +- End-to-end feature tests for creating actions and triggering deliveries from ticket status movement. +- Authorization tests for project admin management and non-admin denial. +- Regression tests for web, API, and MCP ticket status moves. +- HTTP fake tests for webhook and Discord delivery. +- Queue tests for dispatch behavior and retryable failures. + +## Verification + +- `php artisan route:list --except-vendor` +- `php artisan wayfinder:generate --with-form --no-interaction` +- `vendor/bin/pint --dirty --format agent` +- `php artisan test --compact tests/Feature/Notifications tests/Feature/Projects tests/Feature/Api/TicketApiTest.php tests/Feature/Mcp` +- `npm run types:check` +- `git diff --check` + +## Commit And Push + +- Commit only this ticket's changes. +- Push with `git push origin codex/v0.5-webhook-notification/06-notification-tests-and-release-hardening`. + +## Out of Scope + +- New notification channels beyond webhook and Discord +- User bell notifications +- Unread counters +- Incoming webhooks +- API or MCP management endpoints +- OAuth, scoped notification tokens, or external app installations diff --git a/docs/releases/v0.5-webhook-notification/README.md b/docs/releases/v0.5-webhook-notification/README.md new file mode 100644 index 0000000..d2518e1 --- /dev/null +++ b/docs/releases/v0.5-webhook-notification/README.md @@ -0,0 +1,35 @@ +# Release v0.5 - Webhook Notifications + +## Purpose + +Add project-scoped notification actions that can react to KanbanGit events and deliver outbound webhook-style messages. + +The first supported event is `ticket_status_changed`. The first supported channels are a classic JSON webhook for tools like n8n and a Discord webhook with configurable message text. + +## Release Decisions + +- Notification actions are configured per project in project settings. +- Project admins manage notification actions. +- Delivery is queued so ticket status moves are not blocked by slow external services. +- Every delivery attempt is logged for debugging. +- Webhook and Discord URLs are stored as secrets and are not shown back to the user after save. +- Template rendering uses a fixed variable set: `$url`, `$ticket_key`, `$ticket_title`, `$project_name`, `$status_from`, `$status_to`, and `$actor_name`. + +## Tickets + +- [01 Notification Action Foundation](01-notification-action-foundation.md) +- [02 Project Notification Settings UI](02-project-notification-settings-ui.md) +- [03 Ticket Status Changed Trigger](03-ticket-status-changed-trigger.md) +- [04 Generic Webhook Delivery](04-generic-webhook-delivery.md) +- [05 Discord Webhook Delivery](05-discord-webhook-delivery.md) +- [06 Notification Tests and Release Hardening](06-notification-tests-and-release-hardening.md) + +## Out of Scope + +- User bell notifications +- Unread counters +- Slack, Teams, email, or other channels +- Incoming webhooks +- Per-user notification preferences +- API or MCP management endpoints for notification actions +- Event types beyond `ticket_status_changed` From e4762f8e32fa029ade60f73631c678ce9dd5294a Mon Sep 17 00:00:00 2001 From: Eric Peters Date: Mon, 20 Apr 2026 21:59:57 +0200 Subject: [PATCH 08/25] Add notification action foundation --- .../DeleteProjectNotificationAction.php | 17 +++ .../ListProjectNotificationActions.php | 18 +++ .../StoreProjectNotificationAction.php | 45 ++++++ .../UpdateProjectNotificationAction.php | 87 +++++++++++ app/Enums/NotificationChannel.php | 31 ++++ app/Enums/NotificationDeliveryStatus.php | 21 +++ app/Enums/NotificationEventType.php | 29 ++++ .../Resources/NotificationActionResource.php | 34 +++++ .../NotificationDeliveryLogResource.php | 37 +++++ app/Models/NotificationAction.php | 62 ++++++++ app/Models/NotificationDeliveryLog.php | 53 +++++++ app/Models/Project.php | 7 + .../factories/NotificationActionFactory.php | 58 +++++++ .../NotificationDeliveryLogFactory.php | 98 ++++++++++++ ...4646_create_notification_actions_table.php | 38 +++++ ...reate_notification_delivery_logs_table.php | 41 +++++ database/seeders/DatabaseSeeder.php | 2 + database/seeders/NotificationActionSeeder.php | 52 +++++++ .../seeders/NotificationDeliveryLogSeeder.php | 42 +++++ .../Feature/Models/NotificationActionTest.php | 65 ++++++++ .../Models/NotificationDeliveryLogTest.php | 66 ++++++++ ...rojectNotificationActionManagementTest.php | 144 ++++++++++++++++++ 22 files changed, 1047 insertions(+) create mode 100644 app/Actions/Notifications/DeleteProjectNotificationAction.php create mode 100644 app/Actions/Notifications/ListProjectNotificationActions.php create mode 100644 app/Actions/Notifications/StoreProjectNotificationAction.php create mode 100644 app/Actions/Notifications/UpdateProjectNotificationAction.php create mode 100644 app/Enums/NotificationChannel.php create mode 100644 app/Enums/NotificationDeliveryStatus.php create mode 100644 app/Enums/NotificationEventType.php create mode 100644 app/Http/Resources/NotificationActionResource.php create mode 100644 app/Http/Resources/NotificationDeliveryLogResource.php create mode 100644 app/Models/NotificationAction.php create mode 100644 app/Models/NotificationDeliveryLog.php create mode 100644 database/factories/NotificationActionFactory.php create mode 100644 database/factories/NotificationDeliveryLogFactory.php create mode 100644 database/migrations/2026_04_20_194646_create_notification_actions_table.php create mode 100644 database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php create mode 100644 database/seeders/NotificationActionSeeder.php create mode 100644 database/seeders/NotificationDeliveryLogSeeder.php create mode 100644 tests/Feature/Models/NotificationActionTest.php create mode 100644 tests/Feature/Models/NotificationDeliveryLogTest.php create mode 100644 tests/Feature/Notifications/ProjectNotificationActionManagementTest.php diff --git a/app/Actions/Notifications/DeleteProjectNotificationAction.php b/app/Actions/Notifications/DeleteProjectNotificationAction.php new file mode 100644 index 0000000..2c8d349 --- /dev/null +++ b/app/Actions/Notifications/DeleteProjectNotificationAction.php @@ -0,0 +1,17 @@ +notificationActions() + ->whereKey($notificationAction->id) + ->firstOrFail() + ->delete(); + } +} diff --git a/app/Actions/Notifications/ListProjectNotificationActions.php b/app/Actions/Notifications/ListProjectNotificationActions.php new file mode 100644 index 0000000..6462a9b --- /dev/null +++ b/app/Actions/Notifications/ListProjectNotificationActions.php @@ -0,0 +1,18 @@ + + */ + public function handle(Project $project): Collection + { + return $project->notificationActions()->get(); + } +} diff --git a/app/Actions/Notifications/StoreProjectNotificationAction.php b/app/Actions/Notifications/StoreProjectNotificationAction.php new file mode 100644 index 0000000..17d3270 --- /dev/null +++ b/app/Actions/Notifications/StoreProjectNotificationAction.php @@ -0,0 +1,45 @@ +|null, + * delivery_url: string, + * enabled: bool, + * event_type: mixed, + * message_template?: string|null, + * name: string + * } $validated + */ + public function handle(Project $project, array $validated): NotificationAction + { + return $project->notificationActions()->create([ + 'channel' => Arr::get($validated, 'channel'), + 'channel_configuration' => Arr::get($validated, 'channel_configuration') ?? [], + 'delivery_url' => trim((string) Arr::get($validated, 'delivery_url')), + 'enabled' => (bool) Arr::get($validated, 'enabled'), + 'event_type' => Arr::get($validated, 'event_type'), + 'message_template' => $this->normalizeMessageTemplate(Arr::get($validated, 'message_template')), + 'name' => trim((string) Arr::get($validated, 'name')), + ]); + } + + private function normalizeMessageTemplate(mixed $messageTemplate): ?string + { + if (! is_string($messageTemplate)) { + return null; + } + + $messageTemplate = trim($messageTemplate); + + return $messageTemplate === '' ? null : $messageTemplate; + } +} diff --git a/app/Actions/Notifications/UpdateProjectNotificationAction.php b/app/Actions/Notifications/UpdateProjectNotificationAction.php new file mode 100644 index 0000000..59faabd --- /dev/null +++ b/app/Actions/Notifications/UpdateProjectNotificationAction.php @@ -0,0 +1,87 @@ +|null, + * delivery_url?: string|null, + * enabled?: bool, + * event_type?: mixed, + * message_template?: string|null, + * name?: string + * } $validated + */ + public function handle( + Project $project, + NotificationAction $notificationAction, + array $validated, + ): NotificationAction { + $notificationAction = $this->resolveProjectNotificationAction($project, $notificationAction); + + $attributes = []; + + if (array_key_exists('name', $validated)) { + $attributes['name'] = trim((string) Arr::get($validated, 'name')); + } + + if (array_key_exists('enabled', $validated)) { + $attributes['enabled'] = (bool) Arr::get($validated, 'enabled'); + } + + if (array_key_exists('event_type', $validated)) { + $attributes['event_type'] = Arr::get($validated, 'event_type'); + } + + if (array_key_exists('channel', $validated)) { + $attributes['channel'] = Arr::get($validated, 'channel'); + } + + if (array_key_exists('channel_configuration', $validated)) { + $attributes['channel_configuration'] = Arr::get($validated, 'channel_configuration') ?? []; + } + + if (array_key_exists('message_template', $validated)) { + $attributes['message_template'] = $this->normalizeMessageTemplate( + Arr::get($validated, 'message_template'), + ); + } + + $deliveryUrl = Arr::get($validated, 'delivery_url'); + + if (is_string($deliveryUrl) && trim($deliveryUrl) !== '') { + $attributes['delivery_url'] = trim($deliveryUrl); + } + + $notificationAction->update($attributes); + + return $notificationAction->fresh(); + } + + private function normalizeMessageTemplate(mixed $messageTemplate): ?string + { + if (! is_string($messageTemplate)) { + return null; + } + + $messageTemplate = trim($messageTemplate); + + return $messageTemplate === '' ? null : $messageTemplate; + } + + private function resolveProjectNotificationAction( + Project $project, + NotificationAction $notificationAction, + ): NotificationAction { + return $project->notificationActions() + ->whereKey($notificationAction->id) + ->firstOrFail(); + } +} diff --git a/app/Enums/NotificationChannel.php b/app/Enums/NotificationChannel.php new file mode 100644 index 0000000..a869ce7 --- /dev/null +++ b/app/Enums/NotificationChannel.php @@ -0,0 +1,31 @@ + 'Discord', + self::Webhook => 'Webhook', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $channel): array => [ + 'label' => $channel->label(), + 'value' => $channel->value, + ], + self::cases(), + ); + } +} diff --git a/app/Enums/NotificationDeliveryStatus.php b/app/Enums/NotificationDeliveryStatus.php new file mode 100644 index 0000000..cd9ef60 --- /dev/null +++ b/app/Enums/NotificationDeliveryStatus.php @@ -0,0 +1,21 @@ + 'Failed', + self::Pending => 'Pending', + self::Retryable => 'Retryable', + self::Succeeded => 'Succeeded', + }; + } +} diff --git a/app/Enums/NotificationEventType.php b/app/Enums/NotificationEventType.php new file mode 100644 index 0000000..c9e120b --- /dev/null +++ b/app/Enums/NotificationEventType.php @@ -0,0 +1,29 @@ + 'Ticket status changed', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $eventType): array => [ + 'label' => $eventType->label(), + 'value' => $eventType->value, + ], + self::cases(), + ); + } +} diff --git a/app/Http/Resources/NotificationActionResource.php b/app/Http/Resources/NotificationActionResource.php new file mode 100644 index 0000000..d510ae2 --- /dev/null +++ b/app/Http/Resources/NotificationActionResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'project_id' => $this->project_id, + 'name' => $this->name, + 'enabled' => $this->enabled, + 'event_type' => $this->event_type->value, + 'event_type_label' => $this->event_type->label(), + 'channel' => $this->channel->value, + 'channel_label' => $this->channel->label(), + 'channel_configuration' => $this->channel_configuration ?? [], + 'has_delivery_url' => $this->resource instanceof NotificationAction && filled($this->delivery_url), + 'message_template' => $this->message_template, + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Resources/NotificationDeliveryLogResource.php b/app/Http/Resources/NotificationDeliveryLogResource.php new file mode 100644 index 0000000..f485129 --- /dev/null +++ b/app/Http/Resources/NotificationDeliveryLogResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'notification_action_id' => $this->notification_action_id, + 'event_type' => $this->event_type->value, + 'event_type_label' => $this->event_type->label(), + 'channel' => $this->channel->value, + 'channel_label' => $this->channel->label(), + 'delivery_status' => $this->delivery_status->value, + 'delivery_status_label' => $this->delivery_status->label(), + 'attempt_count' => $this->attempt_count, + 'http_status' => $this->http_status, + 'response_excerpt' => $this->response_excerpt, + 'error_excerpt' => $this->error_excerpt, + 'payload_metadata' => $this->payload_metadata, + 'last_attempted_at' => $this->last_attempted_at?->toISOString(), + 'delivered_at' => $this->delivered_at?->toISOString(), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php new file mode 100644 index 0000000..3e42278 --- /dev/null +++ b/app/Models/NotificationAction.php @@ -0,0 +1,62 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'channel' => NotificationChannel::class, + 'channel_configuration' => 'array', + 'delivery_url' => 'encrypted', + 'enabled' => 'boolean', + 'event_type' => NotificationEventType::class, + ]; + } + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('enabled', true); + } + + public function deliveryLogs(): HasMany + { + return $this->hasMany(NotificationDeliveryLog::class) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } +} diff --git a/app/Models/NotificationDeliveryLog.php b/app/Models/NotificationDeliveryLog.php new file mode 100644 index 0000000..0a24637 --- /dev/null +++ b/app/Models/NotificationDeliveryLog.php @@ -0,0 +1,53 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'attempt_count' => 'integer', + 'channel' => NotificationChannel::class, + 'delivered_at' => 'datetime', + 'delivery_status' => NotificationDeliveryStatus::class, + 'event_type' => NotificationEventType::class, + 'http_status' => 'integer', + 'last_attempted_at' => 'datetime', + 'payload_metadata' => 'array', + ]; + } + + public function notificationAction(): BelongsTo + { + return $this->belongsTo(NotificationAction::class); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 5ee7809..51cd5f5 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -88,6 +88,13 @@ class Project extends Model ->orderBy('repository_full_name'); } + public function notificationActions(): HasMany + { + return $this->hasMany(NotificationAction::class) + ->orderBy('name') + ->orderBy('id'); + } + public function tickets(): HasMany { return $this->hasMany(Ticket::class) diff --git a/database/factories/NotificationActionFactory.php b/database/factories/NotificationActionFactory.php new file mode 100644 index 0000000..59daa68 --- /dev/null +++ b/database/factories/NotificationActionFactory.php @@ -0,0 +1,58 @@ + + */ +class NotificationActionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'channel' => NotificationChannel::Webhook, + 'channel_configuration' => [], + 'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(), + 'enabled' => true, + 'event_type' => NotificationEventType::TicketStatusChanged, + 'message_template' => null, + 'name' => fake()->unique()->sentence(3), + 'project_id' => Project::factory(), + ]; + } + + public function discord(): static + { + return $this->state(fn (): array => [ + 'channel' => NotificationChannel::Discord, + 'delivery_url' => 'https://discord.com/api/webhooks/'.fake()->numberBetween(100_000, 999_999).'/'.fake()->uuid(), + 'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url', + ]); + } + + public function disabled(): static + { + return $this->state(fn (): array => [ + 'enabled' => false, + ]); + } + + public function webhook(): static + { + return $this->state(fn (): array => [ + 'channel' => NotificationChannel::Webhook, + 'message_template' => null, + ]); + } +} diff --git a/database/factories/NotificationDeliveryLogFactory.php b/database/factories/NotificationDeliveryLogFactory.php new file mode 100644 index 0000000..23a33eb --- /dev/null +++ b/database/factories/NotificationDeliveryLogFactory.php @@ -0,0 +1,98 @@ + + */ +class NotificationDeliveryLogFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'attempt_count' => 0, + 'channel' => NotificationChannel::Webhook, + 'delivered_at' => null, + 'delivery_status' => NotificationDeliveryStatus::Pending, + 'error_excerpt' => null, + 'event_type' => NotificationEventType::TicketStatusChanged, + 'http_status' => null, + 'last_attempted_at' => null, + 'notification_action_id' => NotificationAction::factory()->webhook(), + 'payload_metadata' => [ + 'project_key' => fake()->regexify('[A-Z]{4}'), + 'ticket_key' => fake()->regexify('[A-Z]{4}-[0-9]{1,3}'), + ], + 'response_excerpt' => null, + ]; + } + + public function forNotificationAction(NotificationAction $notificationAction): static + { + return $this->for($notificationAction, 'notificationAction')->state(fn (): array => [ + 'channel' => $notificationAction->channel, + 'event_type' => $notificationAction->event_type, + ]); + } + + public function failed(): static + { + return $this->state(fn (): array => [ + 'attempt_count' => 3, + 'delivery_status' => NotificationDeliveryStatus::Failed, + 'error_excerpt' => 'Webhook returned a permanent validation error.', + 'http_status' => 422, + 'last_attempted_at' => Carbon::now(), + ]); + } + + public function pending(): static + { + return $this->state(fn (): array => [ + 'attempt_count' => 0, + 'delivered_at' => null, + 'delivery_status' => NotificationDeliveryStatus::Pending, + 'error_excerpt' => null, + 'http_status' => null, + 'last_attempted_at' => null, + 'response_excerpt' => null, + ]); + } + + public function retryable(): static + { + return $this->state(fn (): array => [ + 'attempt_count' => 1, + 'delivery_status' => NotificationDeliveryStatus::Retryable, + 'error_excerpt' => 'Connection timed out while contacting the remote endpoint.', + 'http_status' => null, + 'last_attempted_at' => Carbon::now(), + ]); + } + + public function succeeded(): static + { + return $this->state(fn (): array => [ + 'attempt_count' => 1, + 'delivered_at' => Carbon::now(), + 'delivery_status' => NotificationDeliveryStatus::Succeeded, + 'error_excerpt' => null, + 'http_status' => 204, + 'last_attempted_at' => Carbon::now(), + 'response_excerpt' => 'No Content', + ]); + } +} diff --git a/database/migrations/2026_04_20_194646_create_notification_actions_table.php b/database/migrations/2026_04_20_194646_create_notification_actions_table.php new file mode 100644 index 0000000..ad4e055 --- /dev/null +++ b/database/migrations/2026_04_20_194646_create_notification_actions_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->boolean('enabled')->default(true); + $table->string('event_type'); + $table->string('channel'); + $table->json('channel_configuration'); + $table->text('message_template')->nullable(); + $table->text('delivery_url'); + $table->timestamps(); + + $table->index(['project_id', 'event_type', 'enabled']); + $table->index(['project_id', 'channel']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notification_actions'); + } +}; diff --git a/database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php b/database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php new file mode 100644 index 0000000..8dd33b8 --- /dev/null +++ b/database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('notification_action_id')->constrained()->cascadeOnDelete(); + $table->string('event_type'); + $table->string('channel'); + $table->string('delivery_status'); + $table->unsignedInteger('attempt_count')->default(0); + $table->unsignedSmallInteger('http_status')->nullable(); + $table->text('response_excerpt')->nullable(); + $table->text('error_excerpt')->nullable(); + $table->json('payload_metadata')->nullable(); + $table->timestamp('last_attempted_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + + $table->index(['notification_action_id', 'delivery_status']); + $table->index(['notification_action_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notification_delivery_logs'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 33909c8..3ef6473 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,8 @@ class DatabaseSeeder extends Seeder UserSeeder::class, UserGitConnectionSeeder::class, ProjectSeeder::class, + NotificationActionSeeder::class, + NotificationDeliveryLogSeeder::class, RepositoryConnectionSeeder::class, BoardColumnSeeder::class, ProjectTicketMetadataSeeder::class, diff --git a/database/seeders/NotificationActionSeeder.php b/database/seeders/NotificationActionSeeder.php new file mode 100644 index 0000000..8abcee3 --- /dev/null +++ b/database/seeders/NotificationActionSeeder.php @@ -0,0 +1,52 @@ +where('key', 'CORE')->first() + ?? Project::query()->orderBy('id')->first(); + + if ($project === null) { + return; + } + + $project->notificationActions()->updateOrCreate( + [ + 'name' => 'Core Status Webhook', + ], + [ + 'channel' => NotificationChannel::Webhook, + 'channel_configuration' => [], + 'delivery_url' => 'https://hooks.example.test/kanbangit/core-status', + 'enabled' => true, + 'event_type' => NotificationEventType::TicketStatusChanged, + 'message_template' => null, + ], + ); + + $project->notificationActions()->updateOrCreate( + [ + 'name' => 'Core Status Discord', + ], + [ + 'channel' => NotificationChannel::Discord, + 'channel_configuration' => [], + 'delivery_url' => 'https://discord.com/api/webhooks/123456/core-status', + 'enabled' => false, + 'event_type' => NotificationEventType::TicketStatusChanged, + 'message_template' => '$ticket_key moved from $status_from to $status_to: $url', + ], + ); + } +} diff --git a/database/seeders/NotificationDeliveryLogSeeder.php b/database/seeders/NotificationDeliveryLogSeeder.php new file mode 100644 index 0000000..b21354a --- /dev/null +++ b/database/seeders/NotificationDeliveryLogSeeder.php @@ -0,0 +1,42 @@ +orderBy('id')->first(); + + if ($notificationAction === null || $notificationAction->deliveryLogs()->exists()) { + return; + } + + NotificationDeliveryLog::factory() + ->forNotificationAction($notificationAction) + ->succeeded() + ->create([ + 'payload_metadata' => [ + 'project_key' => 'CORE', + 'ticket_key' => 'CORE-1', + ], + ]); + + NotificationDeliveryLog::factory() + ->forNotificationAction($notificationAction) + ->retryable() + ->create([ + 'payload_metadata' => [ + 'project_key' => 'CORE', + 'ticket_key' => 'CORE-2', + ], + ]); + } +} diff --git a/tests/Feature/Models/NotificationActionTest.php b/tests/Feature/Models/NotificationActionTest.php new file mode 100644 index 0000000..014aeac --- /dev/null +++ b/tests/Feature/Models/NotificationActionTest.php @@ -0,0 +1,65 @@ +webhook()->create([ + 'delivery_url' => 'https://hooks.example.test/kanbangit/core-status', + ]); + + $storedDeliveryUrl = DB::table('notification_actions') + ->where('id', $notificationAction->id) + ->value('delivery_url'); + + $this->assertNotSame('https://hooks.example.test/kanbangit/core-status', $storedDeliveryUrl); + $this->assertSame('https://hooks.example.test/kanbangit/core-status', $notificationAction->fresh()->delivery_url); + $this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->fresh()->event_type); + $this->assertSame(NotificationChannel::Webhook, $notificationAction->fresh()->channel); + $this->assertSame([], $notificationAction->fresh()->channel_configuration); + } + + /** + * Ensure the Discord factory state creates message-driven Discord actions. + */ + public function test_factory_can_create_discord_notification_actions(): void + { + $notificationAction = NotificationAction::factory()->discord()->create(); + + $this->assertSame(NotificationChannel::Discord, $notificationAction->fresh()->channel); + $this->assertNotNull($notificationAction->fresh()->message_template); + $this->assertStringStartsWith('https://discord.com/api/webhooks/', $notificationAction->fresh()->delivery_url); + } + + /** + * Ensure notification actions belong to a project and keep secret URLs out of serialized output. + */ + public function test_notification_actions_belong_to_projects_and_hide_delivery_urls(): void + { + $notificationAction = NotificationAction::factory()->create(); + $resource = (new NotificationActionResource($notificationAction->fresh())) + ->toArray(Request::create('/')); + + $this->assertTrue($notificationAction->project()->exists()); + $this->assertTrue($notificationAction->project->is($notificationAction->fresh()->project)); + $this->assertArrayNotHasKey('delivery_url', $notificationAction->fresh()->toArray()); + $this->assertArrayNotHasKey('delivery_url', $resource); + $this->assertTrue($resource['has_delivery_url']); + } +} diff --git a/tests/Feature/Models/NotificationDeliveryLogTest.php b/tests/Feature/Models/NotificationDeliveryLogTest.php new file mode 100644 index 0000000..2c4b7ad --- /dev/null +++ b/tests/Feature/Models/NotificationDeliveryLogTest.php @@ -0,0 +1,66 @@ +create(); + + $this->assertTrue($deliveryLog->notificationAction()->exists()); + $this->assertTrue($deliveryLog->notificationAction->is($deliveryLog->fresh()->notificationAction)); + $this->assertSame(NotificationChannel::Webhook, $deliveryLog->fresh()->channel); + $this->assertSame(NotificationDeliveryStatus::Pending, $deliveryLog->fresh()->delivery_status); + } + + /** + * Ensure delivery logs can represent every supported delivery state. + */ + public function test_delivery_logs_support_pending_succeeded_failed_and_retryable_statuses(): void + { + NotificationDeliveryLog::factory()->pending()->create(); + NotificationDeliveryLog::factory()->succeeded()->create(); + NotificationDeliveryLog::factory()->failed()->create(); + NotificationDeliveryLog::factory()->retryable()->create(); + + $this->assertSame( + [ + NotificationDeliveryStatus::Pending, + NotificationDeliveryStatus::Succeeded, + NotificationDeliveryStatus::Failed, + NotificationDeliveryStatus::Retryable, + ], + NotificationDeliveryLog::query()->orderBy('id')->get()->pluck('delivery_status')->all(), + ); + } + + /** + * Ensure delivery logs cast metadata and attempt information for later delivery tracking. + */ + public function test_delivery_logs_cast_metadata_and_attempt_information(): void + { + $deliveryLog = NotificationDeliveryLog::factory()->succeeded()->create([ + 'payload_metadata' => [ + 'project_key' => 'CORE', + 'ticket_key' => 'CORE-27', + ], + ])->fresh(); + + $this->assertSame('CORE-27', $deliveryLog->payload_metadata['ticket_key']); + $this->assertSame(204, $deliveryLog->http_status); + $this->assertNotNull($deliveryLog->last_attempted_at); + $this->assertNotNull($deliveryLog->delivered_at); + } +} diff --git a/tests/Feature/Notifications/ProjectNotificationActionManagementTest.php b/tests/Feature/Notifications/ProjectNotificationActionManagementTest.php new file mode 100644 index 0000000..fa4c978 --- /dev/null +++ b/tests/Feature/Notifications/ProjectNotificationActionManagementTest.php @@ -0,0 +1,144 @@ +create(); + $otherProject = Project::factory()->create(); + + $storeAction->handle($project, [ + 'name' => 'Discord alerts', + 'enabled' => false, + 'event_type' => NotificationEventType::TicketStatusChanged, + 'channel' => NotificationChannel::Discord, + 'channel_configuration' => ['mentions' => ['ops']], + 'message_template' => '$ticket_key changed to $status_to', + 'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts', + ]); + + $webhookAction = $storeAction->handle($project, [ + 'name' => 'Webhook alerts', + 'enabled' => true, + 'event_type' => NotificationEventType::TicketStatusChanged, + 'channel' => NotificationChannel::Webhook, + 'channel_configuration' => ['source' => 'n8n'], + 'message_template' => null, + 'delivery_url' => 'https://hooks.example.test/kanbangit/webhook-alerts', + ]); + + NotificationAction::factory()->for($otherProject)->create([ + 'name' => 'Foreign project webhook', + ]); + + $listedActions = $listAction->handle($project); + + $this->assertModelExists($webhookAction); + $this->assertCount(2, $listedActions); + $this->assertSame(['Discord alerts', 'Webhook alerts'], $listedActions->pluck('name')->all()); + $this->assertSame(['mentions' => ['ops']], $listedActions->first()->channel_configuration); + } + + /** + * Ensure notification actions can be updated without overwriting their saved secret URL. + */ + public function test_notification_actions_can_be_updated_without_replacing_the_existing_delivery_url(): void + { + $updateAction = app(UpdateProjectNotificationAction::class); + $project = Project::factory()->create(); + $notificationAction = NotificationAction::factory()->discord()->for($project)->create([ + 'delivery_url' => 'https://discord.com/api/webhooks/123456/original-secret', + 'message_template' => '$ticket_key moved to $status_to', + ]); + + $updatedAction = $updateAction->handle($project, $notificationAction, [ + 'enabled' => false, + 'message_template' => '$ticket_key moved from $status_from to $status_to', + 'channel_configuration' => ['mentions' => ['support']], + 'delivery_url' => '', + ]); + + $this->assertFalse($updatedAction->enabled); + $this->assertSame( + '$ticket_key moved from $status_from to $status_to', + $updatedAction->message_template, + ); + $this->assertSame(['mentions' => ['support']], $updatedAction->channel_configuration); + $this->assertSame( + 'https://discord.com/api/webhooks/123456/original-secret', + $updatedAction->delivery_url, + ); + } + + /** + * Ensure notification actions can replace their delivery URL when a new secret is submitted. + */ + public function test_notification_actions_can_replace_delivery_urls(): void + { + $updateAction = app(UpdateProjectNotificationAction::class); + $project = Project::factory()->create(); + $notificationAction = NotificationAction::factory()->for($project)->create([ + 'delivery_url' => 'https://hooks.example.test/kanbangit/original-secret', + ]); + + $updatedAction = $updateAction->handle($project, $notificationAction, [ + 'delivery_url' => 'https://hooks.example.test/kanbangit/replaced-secret', + 'name' => 'Primary webhook', + ]); + + $this->assertSame('Primary webhook', $updatedAction->name); + $this->assertSame('https://hooks.example.test/kanbangit/replaced-secret', $updatedAction->delivery_url); + } + + /** + * Ensure notification actions can be deleted from their owning project. + */ + public function test_notification_actions_can_be_deleted_from_their_project(): void + { + $deleteAction = app(DeleteProjectNotificationAction::class); + $project = Project::factory()->create(); + $notificationAction = NotificationAction::factory()->for($project)->create(); + + $deleteAction->handle($project, $notificationAction); + + $this->assertModelMissing($notificationAction); + } + + /** + * Ensure project-scoped actions reject notification actions from other projects. + */ + public function test_project_scoped_management_rejects_foreign_notification_actions(): void + { + $updateAction = app(UpdateProjectNotificationAction::class); + $project = Project::factory()->create(); + $otherProject = Project::factory()->create(); + $notificationAction = NotificationAction::factory()->for($otherProject)->create(); + + $this->expectException(ModelNotFoundException::class); + + $updateAction->handle($project, $notificationAction, [ + 'name' => 'Should not update', + ]); + } +} From ee15efe73990ff99171a38ce1d535d57a319c695 Mon Sep 17 00:00:00 2001 From: Eric Peters Date: Mon, 20 Apr 2026 22:29:46 +0200 Subject: [PATCH 09/25] Add project notification settings UI --- ...uildProjectNotificationSettingsPayload.php | 70 ++ .../ProjectNotificationSettingsController.php | 94 +++ .../StoreProjectNotificationActionRequest.php | 71 ++ ...UpdateProjectNotificationActionRequest.php | 71 ++ resources/js/pages/projects/Show.vue | 7 + .../js/pages/projects/settings/Board.vue | 14 + .../js/pages/projects/settings/General.vue | 7 + .../js/pages/projects/settings/Metadata.vue | 7 + .../pages/projects/settings/Notifications.vue | 640 ++++++++++++++++++ .../pages/projects/settings/Repositories.vue | 7 + .../types/models/notification-action-model.ts | 15 + .../notification-action-option-model.ts | 4 + ...fication-action-template-variable-model.ts | 4 + resources/js/types/page-props.ts | 12 + routes/web/project/settings.php | 10 + routes/web/project/settings/notifications.php | 7 + ...jectNotificationSettingsControllerTest.php | 334 +++++++++ 17 files changed, 1374 insertions(+) create mode 100644 app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php create mode 100644 app/Http/Controllers/ProjectNotificationSettingsController.php create mode 100644 app/Http/Requests/StoreProjectNotificationActionRequest.php create mode 100644 app/Http/Requests/UpdateProjectNotificationActionRequest.php create mode 100644 resources/js/pages/projects/settings/Notifications.vue create mode 100644 resources/js/types/models/notification-action-model.ts create mode 100644 resources/js/types/models/notification-action-option-model.ts create mode 100644 resources/js/types/models/notification-action-template-variable-model.ts create mode 100644 routes/web/project/settings/notifications.php create mode 100644 tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php new file mode 100644 index 0000000..995a4f3 --- /dev/null +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -0,0 +1,70 @@ + + */ + public function handle(Project $project, mixed $status = null): array + { + return [ + 'channelOptions' => NotificationChannel::options(), + 'eventTypeOptions' => NotificationEventType::options(), + 'notificationActions' => NotificationActionResource::collection( + $this->listProjectNotificationActions->handle($project), + )->resolve(), + 'project' => ProjectResource::make($project)->resolve(), + 'status' => $status, + 'templateVariables' => $this->templateVariables(), + ]; + } + + /** + * @return array + */ + private function templateVariables(): array + { + return [ + [ + 'description' => 'Absolute ticket URL for the status change event.', + 'value' => '$url', + ], + [ + 'description' => 'Project ticket key such as CORE-42.', + 'value' => '$ticket_key', + ], + [ + 'description' => 'Ticket title from the changed ticket.', + 'value' => '$ticket_title', + ], + [ + 'description' => 'Project name that owns the ticket.', + 'value' => '$project_name', + ], + [ + 'description' => 'Origin status label before the move.', + 'value' => '$status_from', + ], + [ + 'description' => 'Destination status label after the move.', + 'value' => '$status_to', + ], + [ + 'description' => 'Actor name when the move has a known user.', + 'value' => '$actor_name', + ], + ]; + } +} diff --git a/app/Http/Controllers/ProjectNotificationSettingsController.php b/app/Http/Controllers/ProjectNotificationSettingsController.php new file mode 100644 index 0000000..8abb088 --- /dev/null +++ b/app/Http/Controllers/ProjectNotificationSettingsController.php @@ -0,0 +1,94 @@ +abortUnlessCanManage($request, $project); + + return Inertia::render( + 'projects/settings/Notifications', + $this->buildProjectNotificationSettingsPayload->handle($project, session('status')), + ); + } + + public function store( + StoreProjectNotificationActionRequest $request, + Project $project, + ): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + + $notificationAction = $this->storeProjectNotificationAction->handle($project, $request->validated()); + + return to_route('projects.settings.notifications', $project) + ->with('status', "{$notificationAction->name} notification was added."); + } + + public function update( + UpdateProjectNotificationActionRequest $request, + Project $project, + NotificationAction $notificationAction, + ): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + + $notificationAction = $this->updateProjectNotificationAction->handle( + $project, + $notificationAction, + $request->validated(), + ); + + return to_route('projects.settings.notifications', $project) + ->with('status', "{$notificationAction->name} notification was updated."); + } + + public function destroy( + Request $request, + Project $project, + NotificationAction $notificationAction, + ): RedirectResponse { + $this->abortUnlessCanManage($request, $project); + + $notificationAction = $project->notificationActions() + ->whereKey($notificationAction->id) + ->firstOrFail(); + $deletedName = $notificationAction->name; + + $this->deleteProjectNotificationAction->handle($project, $notificationAction); + + return to_route('projects.settings.notifications', $project) + ->with('status', "{$deletedName} notification was deleted."); + } + + private function abortUnlessCanManage(Request $request, Project $project): void + { + abort_unless( + $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), + 403, + ); + } +} diff --git a/app/Http/Requests/StoreProjectNotificationActionRequest.php b/app/Http/Requests/StoreProjectNotificationActionRequest.php new file mode 100644 index 0000000..ed7f636 --- /dev/null +++ b/app/Http/Requests/StoreProjectNotificationActionRequest.php @@ -0,0 +1,71 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], + 'channel' => ['required', 'string', Rule::in($this->channels())], + 'enabled' => ['required', 'boolean'], + 'delivery_url' => ['required', 'string', 'max:2048', 'url'], + 'message_template' => $this->messageTemplateRules(), + ]; + } + + /** + * @return array + */ + private function channels(): array + { + return array_map( + static fn (NotificationChannel $channel): string => $channel->value, + NotificationChannel::cases(), + ); + } + + /** + * @return array + */ + private function eventTypes(): array + { + return array_map( + static fn (NotificationEventType $eventType): string => $eventType->value, + NotificationEventType::cases(), + ); + } + + /** + * @return array + */ + private function messageTemplateRules(): array + { + if ($this->input('channel') === NotificationChannel::Discord->value) { + return ['required', 'string', 'max:2000']; + } + + return ['nullable', 'string', 'max:2000']; + } +} diff --git a/app/Http/Requests/UpdateProjectNotificationActionRequest.php b/app/Http/Requests/UpdateProjectNotificationActionRequest.php new file mode 100644 index 0000000..0e281bf --- /dev/null +++ b/app/Http/Requests/UpdateProjectNotificationActionRequest.php @@ -0,0 +1,71 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], + 'channel' => ['required', 'string', Rule::in($this->channels())], + 'enabled' => ['required', 'boolean'], + 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], + 'message_template' => $this->messageTemplateRules(), + ]; + } + + /** + * @return array + */ + private function channels(): array + { + return array_map( + static fn (NotificationChannel $channel): string => $channel->value, + NotificationChannel::cases(), + ); + } + + /** + * @return array + */ + private function eventTypes(): array + { + return array_map( + static fn (NotificationEventType $eventType): string => $eventType->value, + NotificationEventType::cases(), + ); + } + + /** + * @return array + */ + private function messageTemplateRules(): array + { + if ($this->input('channel') === NotificationChannel::Discord->value) { + return ['required', 'string', 'max:2000']; + } + + return ['nullable', 'string', 'max:2000']; + } +} diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index 1591081..46d86bd 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -19,6 +19,7 @@ import { board as boardSettings, general as generalSettings, metadata as metadataSettings, + notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; import type { BreadcrumbItem, ProjectShowPageProps } from '@/types'; @@ -128,6 +129,12 @@ const projectAreas = [ + + diff --git a/resources/js/pages/projects/settings/Board.vue b/resources/js/pages/projects/settings/Board.vue index 1d24f50..e56e69c 100644 --- a/resources/js/pages/projects/settings/Board.vue +++ b/resources/js/pages/projects/settings/Board.vue @@ -19,6 +19,8 @@ import { index, show } from '@/routes/projects/index'; import { board as boardSettings, general as generalSettings, + metadata as metadataSettings, + notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types'; @@ -105,12 +107,24 @@ const statusAllowsTarget = ( + + + + diff --git a/resources/js/pages/projects/settings/General.vue b/resources/js/pages/projects/settings/General.vue index 3ae452a..f309dd8 100644 --- a/resources/js/pages/projects/settings/General.vue +++ b/resources/js/pages/projects/settings/General.vue @@ -20,6 +20,7 @@ import { board as boardSettings, general as generalSettings, metadata as metadataSettings, + notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types'; @@ -98,6 +99,12 @@ const submitDelete = (): void => { + + diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue index 3df2aa9..5b0274c 100644 --- a/resources/js/pages/projects/settings/Metadata.vue +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -20,6 +20,7 @@ import { board as boardSettings, general as generalSettings, metadata as metadataSettings, + notifications as notificationSettings, repositories as repositorySettings, } from '@/routes/projects/settings'; import type { @@ -90,6 +91,12 @@ const replacementTicketTypes = (ticketTypeId: number) => + + diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue new file mode 100644 index 0000000..978e9d2 --- /dev/null +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -0,0 +1,640 @@ + + +