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/Actions/Notifications/BuildTicketStatusChangedPayload.php b/app/Actions/Notifications/BuildTicketStatusChangedPayload.php new file mode 100644 index 0000000..b923fed --- /dev/null +++ b/app/Actions/Notifications/BuildTicketStatusChangedPayload.php @@ -0,0 +1,42 @@ +id, + projectKey: $project->key, + projectName: $project->name, + ticketId: $ticket->id, + ticketKey: $ticket->ticket_key, + ticketTitle: $ticket->title, + ticketUrl: route('projects.tickets.show', [$project, $ticket]), + actorId: $actor?->id, + actorName: $actor?->name, + fromStatusId: $originStatusId, + fromStatusLabel: $originLabel, + toStatusId: $destinationStatus->id, + toStatusLabel: $destinationLabel, + occurredAt: $occurredAt, + ); + } +} 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/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php new file mode 100644 index 0000000..8cdaef5 --- /dev/null +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -0,0 +1,398 @@ + + */ + public function backoff(): array + { + return [15, 60, 300]; + } + + public function handle(): void + { + $notificationAction = NotificationAction::query()->find($this->notificationActionId); + + if ($notificationAction === null) { + return; + } + + if (! in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) { + return; + } + + $deliveryLog = $this->resolveDeliveryLog($notificationAction); + $attemptCount = $this->attempts(); + $attemptedAt = CarbonImmutable::now(); + $discordMessage = null; + + if ($notificationAction->channel === NotificationChannel::Discord) { + $discordMessage = app(RenderDiscordMessageTemplate::class)->handle( + $notificationAction->message_template, + $this->payload, + ); + + $validationError = $this->discordMessageValidationError($discordMessage); + + if ($validationError !== null) { + $this->markFailedWithoutResponse($deliveryLog, $attemptCount, $attemptedAt, $validationError); + $this->fail(new \RuntimeException($validationError)); + + return; + } + } + + try { + $response = match ($notificationAction->channel) { + NotificationChannel::Discord => $this->sendDiscordRequest($notificationAction, $discordMessage ?? ''), + NotificationChannel::Webhook => $this->sendWebhookRequest($notificationAction), + }; + } catch (ConnectionException $exception) { + $this->handleConnectionFailure($deliveryLog, $notificationAction, $attemptCount, $attemptedAt, $exception); + + return; + } + + if ($response->successful()) { + $this->markSucceeded($deliveryLog, $notificationAction, $response, $attemptCount, $attemptedAt); + + return; + } + + if ($this->shouldRetry($response) && $this->hasAttemptsRemaining($attemptCount)) { + $this->markRetryable($deliveryLog, $notificationAction, $response, $attemptCount, $attemptedAt); + $this->release($this->backoffDelay($attemptCount)); + + return; + } + + $this->markFailed($deliveryLog, $notificationAction, $response, $attemptCount, $attemptedAt); + $this->fail(new \RuntimeException($this->responseFailureDescription($notificationAction, $response))); + } + + public function failed(?Throwable $exception): void + { + $deliveryLog = $this->loadDeliveryLog(); + + if ($deliveryLog === null || $deliveryLog->delivery_status === NotificationDeliveryStatus::Succeeded) { + return; + } + + $notificationAction = NotificationAction::query()->find($this->notificationActionId); + + $deliveryLog->update([ + 'attempt_count' => max($deliveryLog->attempt_count, $this->attempts()), + 'delivery_status' => NotificationDeliveryStatus::Failed, + 'delivered_at' => null, + 'error_excerpt' => $deliveryLog->error_excerpt ?? $this->sanitizedExcerpt( + $exception?->getMessage(), + $notificationAction, + ), + 'last_attempted_at' => $deliveryLog->last_attempted_at ?? CarbonImmutable::now(), + ]); + } + + private function handleConnectionFailure( + NotificationDeliveryLog $deliveryLog, + NotificationAction $notificationAction, + int $attemptCount, + CarbonImmutable $attemptedAt, + ConnectionException $exception, + ): void { + $errorExcerpt = $this->sanitizedExcerpt($exception->getMessage(), $notificationAction) + ?? $this->requestFailureDescription($notificationAction->channel); + + if ($this->hasAttemptsRemaining($attemptCount)) { + $deliveryLog->update([ + 'attempt_count' => $attemptCount, + 'delivery_status' => NotificationDeliveryStatus::Retryable, + 'delivered_at' => null, + 'error_excerpt' => $errorExcerpt, + 'http_status' => null, + 'last_attempted_at' => $attemptedAt, + 'response_excerpt' => null, + ]); + + $this->release($this->backoffDelay($attemptCount)); + + return; + } + + $deliveryLog->update([ + 'attempt_count' => $attemptCount, + 'delivery_status' => NotificationDeliveryStatus::Failed, + 'delivered_at' => null, + 'error_excerpt' => $errorExcerpt, + 'http_status' => null, + 'last_attempted_at' => $attemptedAt, + 'response_excerpt' => null, + ]); + + $this->fail($exception); + } + + private function markSucceeded( + NotificationDeliveryLog $deliveryLog, + NotificationAction $notificationAction, + Response $response, + int $attemptCount, + CarbonImmutable $attemptedAt, + ): void { + $deliveryLog->update([ + 'attempt_count' => $attemptCount, + 'delivery_status' => NotificationDeliveryStatus::Succeeded, + 'delivered_at' => $attemptedAt, + 'error_excerpt' => null, + 'http_status' => $response->status(), + 'last_attempted_at' => $attemptedAt, + 'response_excerpt' => $this->responseExcerpt($response, $notificationAction), + ]); + } + + private function markRetryable( + NotificationDeliveryLog $deliveryLog, + NotificationAction $notificationAction, + Response $response, + int $attemptCount, + CarbonImmutable $attemptedAt, + ): void { + $deliveryLog->update([ + 'attempt_count' => $attemptCount, + 'delivery_status' => NotificationDeliveryStatus::Retryable, + 'delivered_at' => null, + 'error_excerpt' => $this->responseFailureDescription($notificationAction, $response), + 'http_status' => $response->status(), + 'last_attempted_at' => $attemptedAt, + 'response_excerpt' => $this->responseExcerpt($response, $notificationAction), + ]); + } + + private function markFailed( + NotificationDeliveryLog $deliveryLog, + NotificationAction $notificationAction, + Response $response, + int $attemptCount, + CarbonImmutable $attemptedAt, + ): void { + $deliveryLog->update([ + 'attempt_count' => $attemptCount, + 'delivery_status' => NotificationDeliveryStatus::Failed, + 'delivered_at' => null, + 'error_excerpt' => $this->responseFailureDescription($notificationAction, $response), + 'http_status' => $response->status(), + 'last_attempted_at' => $attemptedAt, + 'response_excerpt' => $this->responseExcerpt($response, $notificationAction), + ]); + } + + private function markFailedWithoutResponse( + NotificationDeliveryLog $deliveryLog, + int $attemptCount, + CarbonImmutable $attemptedAt, + string $errorExcerpt, + ): void { + $deliveryLog->update([ + 'attempt_count' => $attemptCount, + 'delivery_status' => NotificationDeliveryStatus::Failed, + 'delivered_at' => null, + 'error_excerpt' => $errorExcerpt, + 'http_status' => null, + 'last_attempted_at' => $attemptedAt, + 'response_excerpt' => null, + ]); + } + + private function resolveDeliveryLog(NotificationAction $notificationAction): NotificationDeliveryLog + { + $deliveryLog = $this->loadDeliveryLog(); + + if ($deliveryLog !== null) { + return $deliveryLog; + } + + $deliveryLog = NotificationDeliveryLog::query()->create([ + 'attempt_count' => 0, + 'channel' => $notificationAction->channel, + 'delivery_status' => NotificationDeliveryStatus::Pending, + 'event_type' => $this->payload->eventType, + 'notification_action_id' => $notificationAction->id, + 'payload_metadata' => $this->payloadMetadata(), + ]); + + $this->notificationDeliveryLogId = $deliveryLog->id; + + return $deliveryLog; + } + + private function loadDeliveryLog(): ?NotificationDeliveryLog + { + if ($this->notificationDeliveryLogId === null) { + return null; + } + + return NotificationDeliveryLog::query() + ->whereKey($this->notificationDeliveryLogId) + ->where('notification_action_id', $this->notificationActionId) + ->first(); + } + + private function shouldRetry(Response $response): bool + { + return $response->serverError() || in_array($response->status(), [408, 429], true); + } + + private function sendDiscordRequest(NotificationAction $notificationAction, string $message): Response + { + return Http::acceptJson() + ->asJson() + ->connectTimeout(5) + ->timeout(10) + ->post($notificationAction->delivery_url, [ + 'content' => $message, + 'allowed_mentions' => [ + 'parse' => [], + ], + ]); + } + + private function sendWebhookRequest(NotificationAction $notificationAction): Response + { + return Http::acceptJson() + ->asJson() + ->connectTimeout(5) + ->timeout(10) + ->post($notificationAction->delivery_url, $this->payload->toArray()); + } + + private function hasAttemptsRemaining(int $attemptCount): bool + { + return $attemptCount < $this->tries; + } + + private function backoffDelay(int $attemptCount): int + { + $backoff = $this->backoff(); + $index = min($attemptCount - 1, count($backoff) - 1); + + return $backoff[$index]; + } + + private function responseExcerpt(Response $response, NotificationAction $notificationAction): ?string + { + return $this->sanitizedExcerpt($response->body(), $notificationAction) + ?? $this->sanitizedExcerpt($response->reason(), $notificationAction); + } + + private function responseFailureDescription(NotificationAction $notificationAction, Response $response): string + { + $reason = $this->sanitizedExcerpt($response->reason(), $notificationAction); + $subject = $this->deliverySubject($notificationAction->channel); + + if ($reason === null) { + return "{$subject} returned HTTP {$response->status()}."; + } + + return "{$subject} returned HTTP {$response->status()} {$reason}."; + } + + private function requestFailureDescription(NotificationChannel $channel): string + { + return match ($channel) { + NotificationChannel::Discord => 'Discord webhook request failed.', + NotificationChannel::Webhook => 'Webhook request failed.', + }; + } + + private function deliverySubject(NotificationChannel $channel): string + { + return match ($channel) { + NotificationChannel::Discord => 'Discord webhook', + NotificationChannel::Webhook => 'Webhook', + }; + } + + private function discordMessageValidationError(string $message): ?string + { + if (trim($message) === '') { + return 'Discord message content cannot be empty.'; + } + + if (Str::length($message) > self::DISCORD_CONTENT_LIMIT) { + return 'Discord message content exceeds the 2000 character limit.'; + } + + return null; + } + + /** + * @return array{project_key: string, ticket_key: string} + */ + private function payloadMetadata(): array + { + return [ + 'project_key' => $this->payload->projectKey, + 'ticket_key' => $this->payload->ticketKey, + ]; + } + + private function excerpt(?string $value): ?string + { + if ($value === null) { + return null; + } + + $excerpt = (string) Str::of($value)->trim()->squish()->limit(500); + + return $excerpt === '' ? null : $excerpt; + } + + private function sanitizedExcerpt(?string $value, ?NotificationAction $notificationAction): ?string + { + if ( + $value !== null + && $notificationAction !== null + && filled($notificationAction->delivery_url) + ) { + $value = str_replace( + [ + $notificationAction->delivery_url, + str_replace('/', '\/', $notificationAction->delivery_url), + ], + '[redacted]', + $value, + ); + } + + return $this->excerpt($value); + } +} 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/QueueTicketStatusChangedNotificationDeliveries.php b/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php new file mode 100644 index 0000000..eb195cd --- /dev/null +++ b/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php @@ -0,0 +1,43 @@ +enabled() + ->where('project_id', $event->payload->projectId) + ->where('event_type', $event->payload->eventType->value) + ->get(['channel', 'id']) + ->each(function (NotificationAction $notificationAction) use ($event): void { + $notificationDeliveryLogId = null; + + if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) { + $notificationDeliveryLogId = NotificationDeliveryLog::query()->create([ + 'attempt_count' => 0, + 'channel' => $notificationAction->channel, + 'delivery_status' => NotificationDeliveryStatus::Pending, + 'event_type' => $event->payload->eventType, + 'notification_action_id' => $notificationAction->id, + 'payload_metadata' => [ + 'project_key' => $event->payload->projectKey, + 'ticket_key' => $event->payload->ticketKey, + ], + ])->id; + } + + DeliverNotificationAction::dispatch( + notificationActionId: $notificationAction->id, + payload: $event->payload, + notificationDeliveryLogId: $notificationDeliveryLogId, + ); + }); + } +} diff --git a/app/Actions/Notifications/RenderDiscordMessageTemplate.php b/app/Actions/Notifications/RenderDiscordMessageTemplate.php new file mode 100644 index 0000000..d9be56e --- /dev/null +++ b/app/Actions/Notifications/RenderDiscordMessageTemplate.php @@ -0,0 +1,21 @@ + $payload->ticketUrl, + '$ticket_key' => $payload->ticketKey, + '$ticket_title' => $payload->ticketTitle, + '$project_name' => $payload->projectName, + '$status_from' => $payload->fromStatusLabel, + '$status_to' => $payload->toStatusLabel, + '$actor_name' => $payload->actorName ?? '', + ]); + } +} 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/TicketStatusChanged.php b/app/Actions/Notifications/TicketStatusChanged.php new file mode 100644 index 0000000..245d0b7 --- /dev/null +++ b/app/Actions/Notifications/TicketStatusChanged.php @@ -0,0 +1,15 @@ + + */ +final readonly class TicketStatusChangedPayload implements Arrayable +{ + public function __construct( + public NotificationEventType $eventType, + public int $projectId, + public string $projectKey, + public string $projectName, + public int $ticketId, + public string $ticketKey, + public string $ticketTitle, + public string $ticketUrl, + public ?int $actorId, + public ?string $actorName, + public int $fromStatusId, + public string $fromStatusLabel, + public int $toStatusId, + public string $toStatusLabel, + public CarbonImmutable $occurredAt, + ) {} + + /** + * @return array{ + * event_type: string, + * project: array{id: int, key: string, name: string}, + * ticket: array{id: int, key: string, title: string}, + * actor: array{id: int, name: string}|null, + * from: array{id: int, label: string}, + * to: array{id: int, label: string}, + * ticket_url: string, + * occurred_at: string + * } + */ + public function toArray(): array + { + return [ + 'event_type' => $this->eventType->value, + 'project' => [ + 'id' => $this->projectId, + 'key' => $this->projectKey, + 'name' => $this->projectName, + ], + 'ticket' => [ + 'id' => $this->ticketId, + 'key' => $this->ticketKey, + 'title' => $this->ticketTitle, + ], + 'actor' => $this->actorId === null || $this->actorName === null + ? null + : [ + 'id' => $this->actorId, + 'name' => $this->actorName, + ], + 'from' => [ + 'id' => $this->fromStatusId, + 'label' => $this->fromStatusLabel, + ], + 'to' => [ + 'id' => $this->toStatusId, + 'label' => $this->toStatusLabel, + ], + 'ticket_url' => $this->ticketUrl, + 'occurred_at' => $this->occurredAt->toISOString(), + ]; + } +} 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/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..bff6315 --- /dev/null +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -0,0 +1,68 @@ + + */ + 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() + ->visibleToProject($project) + ->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..25257cb --- /dev/null +++ b/app/Actions/Projects/BuildProjectTicketDetailPayload.php @@ -0,0 +1,125 @@ + + */ + 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() + ->visibleToProject($project) + ->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..7c6a307 --- /dev/null +++ b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php @@ -0,0 +1,47 @@ + + */ + 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() + ->visibleToProject($project) + ->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/ListAssignedTicketsByVisibleProject.php b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php new file mode 100644 index 0000000..de2faf1 --- /dev/null +++ b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php @@ -0,0 +1,63 @@ +, tickets: 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 === []) { + 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/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/Actions/Tickets/MoveProjectTicket.php b/app/Actions/Tickets/MoveProjectTicket.php new file mode 100644 index 0000000..884b8b5 --- /dev/null +++ b/app/Actions/Tickets/MoveProjectTicket.php @@ -0,0 +1,105 @@ +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, + ]); + + $occurredAt = CarbonImmutable::now(); + + $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, + ], + ], + ); + + TicketStatusChanged::dispatch( + $this->buildTicketStatusChangedPayload->handle( + $project, + $ticket, + $user, + $originStatusId, + $originLabel, + $destinationStatus, + $destinationLabel, + $occurredAt, + ), + ); + + 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/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/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/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/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..337fda9 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectTicketController.php @@ -0,0 +1,200 @@ +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.'; + + $canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project); + $displayTicket = $ticket->fresh([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + 'worklogEntries.actor', + ]) ?? $ticket->load([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + 'worklogEntries.actor', + ]); + + if (! $project->show_author && ! $canManageProject) { + $displayTicket->setRelation('author', null); + $displayTicket->author_user_id = null; + } + + return TicketResource::make($displayTicket) + ->additional([ + 'meta' => [ + 'message' => $statusMessage, + ], + ]) + ->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/Api/UserAssignedTicketController.php b/app/Http/Controllers/Api/UserAssignedTicketController.php new file mode 100644 index 0000000..cbd01cb --- /dev/null +++ b/app/Http/Controllers/Api/UserAssignedTicketController.php @@ -0,0 +1,32 @@ +authenticatedUser($request); + + $assignedTickets = $this->listAssignedTicketsByVisibleProject->handle($viewer, $user); + + return response()->json([ + 'data' => $assignedTickets, + 'meta' => [ + 'assignee' => $assignedTickets === [] + ? null + : UserResource::make($user)->resolve(), + ], + ]); + } +} 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/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/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/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/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/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/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/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/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/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..312370e --- /dev/null +++ b/app/Mcp/Tools/ListTicketsByStatus.php @@ -0,0 +1,75 @@ +validate([ + 'project_id' => ['nullable', 'integer'], + 'status_name' => ['nullable', 'string'], + 'status_slug' => ['nullable', 'string'], + 'ticket_status_id' => ['nullable', 'integer', 'required_without_all:status_slug,status_name'], + ], [ + 'ticket_status_id.required_without_all' => 'Provide ticket_status_id, status_slug, or status_name.', + ]); + + $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/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/app/Models/User.php b/app/Models/User.php index ef51f81..b3dc0ba 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,9 +3,12 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -60,6 +63,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'); @@ -69,4 +79,22 @@ class User extends Authenticatable { return $this->hasMany(Ticket::class, 'author_user_id'); } + + public function scopeVisibleToProject(Builder $query, Project $project): Builder + { + $permissionService = app(PermissionService::class); + + $allowedUserIds = User::query() + ->select('id') + ->cursor() + ->filter(fn (User $candidate) => $permissionService->allows( + $candidate, + Permission::ProjectRead, + $project + )) + ->pluck('id') + ->all(); + + return $query->whereKey($allowedUserIds); + } } 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/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 78f4da5..00ab2d1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Actions\Notifications\QueueTicketStatusChangedNotificationDeliveries; +use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; use App\Models\Project; @@ -11,6 +13,7 @@ use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; @@ -37,6 +40,7 @@ class AppServiceProvider extends ServiceProvider } $this->configureAuthorization(); + $this->configureEventListeners(); $this->configureDefaults(); } @@ -85,4 +89,15 @@ class AppServiceProvider extends ServiceProvider : null, ); } + + /** + * Configure application event listeners. + */ + protected function configureEventListeners(): void + { + Event::listen( + TicketStatusChanged::class, + QueueTicketStatusChangedNotificationDeliveries::class, + ); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index d68d267..001ee1c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ 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 { @@ -21,6 +23,10 @@ return Application::configure(basePath: dirname(__DIR__)) HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); + + $middleware->api(append: [ + AuthenticateApiApplicationKey::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // 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..d0a2db5 --- /dev/null +++ b/config/scalar.php @@ -0,0 +1,208 @@ + 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 API client button in the sidebar and modal. + */ + 'hideClientButton' => true, + + 'mcp' => [ + 'name' => 'KanbanGit MCP', + 'url' => '/mcp/kanbangit', + ], + + /** + * 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/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/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/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/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/docs/openapi/kanbangit-api.openapi.json b/docs/openapi/kanbangit-api.openapi.json new file mode 100644 index 0000000..043b565 --- /dev/null +++ b/docs/openapi/kanbangit-api.openapi.json @@ -0,0 +1,2697 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "KanbanGit Application API", + "version": "0.1.0", + "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": [ + { + "ApplicationKey": [] + } + ], + "tags": [ + { + "name": "Projects" + }, + { + "name": "Tickets" + }, + { + "name": "Board" + }, + { + "name": "Metadata" + }, + { + "name": "Repositories" + }, + { + "name": "Git Connections" + }, + { + "name": "Users" + } + ], + "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" + } + } + } + }, + "/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", + "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" + } + } + } + }, + "AssignedTicketsPayload": { + "description": "Tickets assigned to a user grouped by projects visible to the authenticated application key owner. meta.assignee is null when no visible assigned tickets exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignedTicketsPayloadEnvelope" + } + } + } + }, + "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" + } + } + }, + "AssignedTicketsPayloadEnvelope": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssignedTicketsProjectGroup" + } + }, + "meta": { + "type": "object", + "properties": { + "assignee": { + "anyOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "type": "null" + } + ] + } + }, + "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, + "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/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` 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/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..45fba9c --- /dev/null +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -0,0 +1,640 @@ + + +