Merge branch 'main' into epic/ui-rework
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Http\Resources\NotificationActionResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Models\Project;
|
||||
|
||||
class BuildProjectNotificationSettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<int, array{description: string, value: string}>
|
||||
*/
|
||||
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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class BuildTicketStatusChangedPayload
|
||||
{
|
||||
public function handle(
|
||||
Project $project,
|
||||
Ticket $ticket,
|
||||
?User $actor,
|
||||
int $originStatusId,
|
||||
string $originLabel,
|
||||
TicketStatus $destinationStatus,
|
||||
string $destinationLabel,
|
||||
CarbonImmutable $occurredAt,
|
||||
): TicketStatusChangedPayload {
|
||||
return new TicketStatusChangedPayload(
|
||||
eventType: NotificationEventType::TicketStatusChanged,
|
||||
projectId: $project->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
|
||||
class DeleteProjectNotificationAction
|
||||
{
|
||||
public function handle(Project $project, NotificationAction $notificationAction): void
|
||||
{
|
||||
$project->notificationActions()
|
||||
->whereKey($notificationAction->id)
|
||||
->firstOrFail()
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationDeliveryStatus;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\NotificationDeliveryLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class DeliverNotificationAction implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
private const int DISCORD_CONTENT_LIMIT = 2000;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 15;
|
||||
|
||||
public function __construct(
|
||||
public int $notificationActionId,
|
||||
public TicketStatusChangedPayload $payload,
|
||||
public ?int $notificationDeliveryLogId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ListProjectNotificationActions
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, NotificationAction>
|
||||
*/
|
||||
public function handle(Project $project): Collection
|
||||
{
|
||||
return $project->notificationActions()->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationDeliveryStatus;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\NotificationDeliveryLog;
|
||||
|
||||
class QueueTicketStatusChangedNotificationDeliveries
|
||||
{
|
||||
public function handle(TicketStatusChanged $event): void
|
||||
{
|
||||
NotificationAction::query()
|
||||
->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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RenderDiscordMessageTemplate
|
||||
{
|
||||
public function handle(?string $messageTemplate, TicketStatusChangedPayload $payload): string
|
||||
{
|
||||
return strtr((string) Str::of((string) $messageTemplate), [
|
||||
'$url' => $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 ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class StoreProjectNotificationAction
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* channel: mixed,
|
||||
* channel_configuration?: array<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
final readonly class TicketStatusChanged implements ShouldDispatchAfterCommit
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public TicketStatusChangedPayload $payload,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationEventType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
/**
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateProjectNotificationAction
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* channel?: mixed,
|
||||
* channel_configuration?: array<string, mixed>|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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteBoardColumn
|
||||
{
|
||||
use ManagesTicketStatusPositions;
|
||||
use ReordersBoardColumns;
|
||||
|
||||
public function handle(Project $project, BoardColumn $boardColumn): string
|
||||
{
|
||||
$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);
|
||||
});
|
||||
|
||||
return $deletedName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class DeleteTicketStatus
|
||||
{
|
||||
use ManagesTicketStatusPositions;
|
||||
|
||||
/**
|
||||
* @return array{context_label: string, deleted_name: string}
|
||||
*/
|
||||
public function handle(Project $project, TicketStatus $ticketStatus): array
|
||||
{
|
||||
$ticketStatus->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
trait ManagesTicketStatusPositions
|
||||
{
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
trait ReordersBoardColumns
|
||||
{
|
||||
private function normalizeColumnPositions(Project $project): void
|
||||
{
|
||||
$this->persistOrderedColumns(
|
||||
$project->boardColumns()
|
||||
->get()
|
||||
->values(),
|
||||
);
|
||||
}
|
||||
|
||||
private function reorderColumns(Project $project, BoardColumn $movingColumn, int $desiredPosition): void
|
||||
{
|
||||
/** @var Collection<int, BoardColumn> $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<int, BoardColumn> $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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreBoardColumn
|
||||
{
|
||||
use ReordersBoardColumns;
|
||||
|
||||
/**
|
||||
* @param array{name: string, position?: int|null} $validated
|
||||
*/
|
||||
public function handle(Project $project, array $validated): BoardColumn
|
||||
{
|
||||
$desiredPosition = Arr::get($validated, 'position') ?? $project->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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreTicketStatus
|
||||
{
|
||||
use ManagesTicketStatusPositions;
|
||||
|
||||
/**
|
||||
* @param array{board_column_id?: int|null, name: string} $validated
|
||||
*/
|
||||
public function handle(Project $project, array $validated): TicketStatus
|
||||
{
|
||||
$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),
|
||||
]);
|
||||
});
|
||||
|
||||
return $ticketStatus->load('boardColumn');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateBoardColumn
|
||||
{
|
||||
use ReordersBoardColumns;
|
||||
|
||||
/**
|
||||
* @param array{name: string, position: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, BoardColumn $boardColumn, array $validated): BoardColumn
|
||||
{
|
||||
DB::transaction(function () use ($boardColumn, $project, $validated): void {
|
||||
$boardColumn->update([
|
||||
'name' => Arr::get($validated, 'name'),
|
||||
]);
|
||||
|
||||
$this->reorderColumns($project, $boardColumn, Arr::get($validated, 'position'));
|
||||
});
|
||||
|
||||
return $boardColumn->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateTicketStatus
|
||||
{
|
||||
/**
|
||||
* @param array{name: string} $validated
|
||||
*/
|
||||
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
|
||||
{
|
||||
$ticketStatus->update([
|
||||
'name' => Arr::get($validated, 'name'),
|
||||
]);
|
||||
|
||||
return $ticketStatus->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateTicketStatusTransitions
|
||||
{
|
||||
/**
|
||||
* @param array{allowed_ticket_status_ids?: array<int, int|string>|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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketPriority;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProjectTicketPriority
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @return array{deleted: bool, deleted_name: string, message: string}
|
||||
*/
|
||||
public function handle(Project $project, TicketPriority $ticketPriority): array
|
||||
{
|
||||
if ($project->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.",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Actions\Tickets\CreateTicketWorklogEntry;
|
||||
use App\Enums\TicketWorklogEntryType;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProjectTicketType
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
public function __construct(
|
||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{replacement_ticket_type_id?: int|null} $validated
|
||||
* @return array{deleted: bool, deleted_name: string, message: string}
|
||||
*/
|
||||
public function handle(Project $project, TicketType $ticketType, User $actor, array $validated): array
|
||||
{
|
||||
if ($project->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.",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteTicketFieldDefinition
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
public function handle(Project $project, TicketFieldDefinition $ticketFieldDefinition): string
|
||||
{
|
||||
$deletedLabel = $ticketFieldDefinition->label;
|
||||
|
||||
DB::transaction(function () use ($project, $ticketFieldDefinition): void {
|
||||
$ticketFieldDefinition->delete();
|
||||
|
||||
$this->persistOrderedModels($project->ticketFieldDefinitions()->get()->values());
|
||||
});
|
||||
|
||||
return $deletedLabel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketLinkType;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteTicketLinkType
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
public function handle(Project $project, TicketLinkType $ticketLinkType): string
|
||||
{
|
||||
$deletedLabel = $ticketLinkType->label;
|
||||
|
||||
DB::transaction(function () use ($project, $ticketLinkType): void {
|
||||
$ticketLinkType->delete();
|
||||
|
||||
$this->persistOrderedModels($project->ticketLinkTypes()->get()->values());
|
||||
});
|
||||
|
||||
return $deletedLabel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
trait ReordersProjectMetadata
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Model> $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<int, Model> $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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketPriority;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreProjectTicketPriority
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @param array{name: string, position?: int|null} $validated
|
||||
*/
|
||||
public function handle(Project $project, array $validated): TicketPriority
|
||||
{
|
||||
$name = Arr::get($validated, 'name');
|
||||
$position = Arr::get($validated, 'position');
|
||||
|
||||
return 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreProjectTicketType
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @param array{is_parent?: bool|null, name: string, position?: int|null} $validated
|
||||
*/
|
||||
public function handle(Project $project, array $validated): TicketType
|
||||
{
|
||||
return 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreTicketFieldDefinition
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @param array{field_type: string, label: string, position?: int|null} $validated
|
||||
*/
|
||||
public function handle(Project $project, array $validated): TicketFieldDefinition
|
||||
{
|
||||
return 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketLinkType;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreTicketLinkType
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @param array{name: string, position?: int|null} $validated
|
||||
*/
|
||||
public function handle(Project $project, array $validated): TicketLinkType
|
||||
{
|
||||
$name = Arr::get($validated, 'name');
|
||||
$position = Arr::get($validated, 'position');
|
||||
|
||||
return 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateProjectTicketMetadataSettings
|
||||
{
|
||||
/**
|
||||
* @param array{show_author?: bool|null, show_story_points?: bool|null} $validated
|
||||
*/
|
||||
public function handle(Project $project, array $validated): Project
|
||||
{
|
||||
$project->update([
|
||||
'show_author' => Arr::get($validated, 'show_author', false),
|
||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||
]);
|
||||
|
||||
return $project->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketPriority;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateProjectTicketPriority
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @param array{name: string, position: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, TicketPriority $ticketPriority, array $validated): TicketPriority
|
||||
{
|
||||
$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,
|
||||
);
|
||||
});
|
||||
|
||||
return $ticketPriority->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateProjectTicketType
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @param array{name: string, position: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, TicketType $ticketType, array $validated): TicketType
|
||||
{
|
||||
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'),
|
||||
);
|
||||
});
|
||||
|
||||
return $ticketType->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateTicketFieldDefinition
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @param array{field_type: string, label: string, position: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, TicketFieldDefinition $ticketFieldDefinition, array $validated): TicketFieldDefinition
|
||||
{
|
||||
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'),
|
||||
);
|
||||
});
|
||||
|
||||
return $ticketFieldDefinition->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketLinkType;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateTicketLinkType
|
||||
{
|
||||
use ReordersProjectMetadata;
|
||||
|
||||
/**
|
||||
* @param array{name: string, position: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, TicketLinkType $ticketLinkType, array $validated): TicketLinkType
|
||||
{
|
||||
$name = Arr::get($validated, 'name');
|
||||
$position = Arr::get($validated, 'position');
|
||||
|
||||
DB::transaction(function () use ($project, $name, $position, $ticketLinkType): void {
|
||||
$ticketLinkType->update([
|
||||
'label' => $name,
|
||||
]);
|
||||
|
||||
$this->reorderModels(
|
||||
$project->ticketLinkTypes()->whereKeyNot($ticketLinkType->id)->get()->values(),
|
||||
$ticketLinkType,
|
||||
$position,
|
||||
);
|
||||
});
|
||||
|
||||
return $ticketLinkType->fresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
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\Ticket;
|
||||
use App\Models\User;
|
||||
|
||||
class BuildProjectBacklogPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Resources\BoardColumnResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
|
||||
class BuildProjectBoardPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Http\Resources\BoardColumnResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TicketStatusResource;
|
||||
use App\Models\Project;
|
||||
|
||||
class BuildProjectBoardSettingsPayload
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Git\BuildSuggestedTicketBranchName;
|
||||
use App\Actions\Git\ResolveTicketBranchMatches;
|
||||
use App\Actions\Git\ResolveTicketPullRequestMatches;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
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\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;
|
||||
|
||||
class BuildProjectTicketDetailPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
|
||||
private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
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\TicketFieldDefinition;
|
||||
|
||||
class BuildProjectTicketMetadataSettingsPayload
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TicketPriorityResource;
|
||||
use App\Http\Resources\TicketStatusResource;
|
||||
use App\Http\Resources\TicketTypeResource;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
|
||||
class BuildProjectTicketOptionsPayload
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Http\Resources\TicketStatusResource;
|
||||
use App\Models\Ticket;
|
||||
|
||||
class BuildProjectTicketStatusTargetsPayload
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
|
||||
class CreateProject
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{key: string, name: string} $attributes
|
||||
*/
|
||||
public function handle(array $attributes, User $actor): Project
|
||||
{
|
||||
$project = Project::query()->create($attributes);
|
||||
|
||||
$this->initializeProjectTicketMetadata->handle($project);
|
||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
||||
|
||||
return $project;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
class DeleteProject
|
||||
{
|
||||
public function handle(Project $project): string
|
||||
{
|
||||
$projectName = $project->name;
|
||||
|
||||
$project->delete();
|
||||
|
||||
return $projectName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ListVisibleProjects
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Repositories;
|
||||
|
||||
use App\Actions\Git\Data\GitRepository;
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
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\Support\Collection;
|
||||
use Throwable;
|
||||
|
||||
class BuildProjectRepositorySettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<int, array{
|
||||
* account_login: string,
|
||||
* account_name: string|null,
|
||||
* base_url: string,
|
||||
* error: string|null,
|
||||
* provider: string,
|
||||
* provider_label: string,
|
||||
* repositories: array<int, array{
|
||||
* full_name: string,
|
||||
* owner_name: string,
|
||||
* provider_repository_id: string,
|
||||
* repository_name: string,
|
||||
* repository_url: string,
|
||||
* visibility_label: string
|
||||
* }>,
|
||||
* user_git_connection_id: int
|
||||
* }>
|
||||
*/
|
||||
public function availableRepositorySources(User $user): array
|
||||
{
|
||||
/** @var Collection<int, UserGitConnection> $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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Repositories;
|
||||
|
||||
use App\Models\RepositoryConnection;
|
||||
|
||||
class DeleteProjectRepositoryConnection
|
||||
{
|
||||
public function handle(RepositoryConnection $repositoryConnection): string
|
||||
{
|
||||
$repositoryLabel = $repositoryConnection->repository_full_name;
|
||||
|
||||
$repositoryConnection->delete();
|
||||
|
||||
return $repositoryLabel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Repositories;
|
||||
|
||||
use App\Actions\Git\Data\GitRepository;
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
use App\Models\User;
|
||||
use App\Models\UserGitConnection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ResolveRepositorySelectionAttributes
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* base_url: string,
|
||||
* owner_name: string,
|
||||
* provider: mixed,
|
||||
* provider_repository_id: string,
|
||||
* repository_full_name: string,
|
||||
* repository_name: string,
|
||||
* repository_url: string
|
||||
* }
|
||||
*/
|
||||
public function handle(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Repositories;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StoreProjectRepositoryConnection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResolveRepositorySelectionAttributes $resolveRepositorySelectionAttributes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{provider_repository_id: string, user_git_connection_id: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, User $user, array $validated): RepositoryConnection
|
||||
{
|
||||
$repositoryAttributes = $this->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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Repositories;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UpdateProjectRepositoryConnection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResolveRepositorySelectionAttributes $resolveRepositorySelectionAttributes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{provider_repository_id: string, user_git_connection_id: int} $validated
|
||||
*/
|
||||
public function handle(
|
||||
Project $project,
|
||||
RepositoryConnection $repositoryConnection,
|
||||
User $user,
|
||||
array $validated,
|
||||
): RepositoryConnection {
|
||||
$repositoryAttributes = $this->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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
use App\Actions\Git\ValidateGitBranchName;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Models\UserGitConnection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class CreateProjectTicketBranch
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
private readonly ValidateGitBranchName $validateGitBranchName,
|
||||
private readonly ValidateProjectTicketBranch $validateProjectTicketBranch,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{branch_name: string, repository_connection_id: int} $validated
|
||||
* @return array{branch_name: string, repository_full_name: string}
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): array
|
||||
{
|
||||
$repositoryConnectionId = (int) 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,
|
||||
]);
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Enums\TicketWorklogEntryType;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketLink;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProjectTicketLink
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||
) {}
|
||||
|
||||
public function handle(Ticket $ticket, TicketLink $ticketLink, User $user): string
|
||||
{
|
||||
$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,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Actions\Projects\ListVisibleProjects;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TicketResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ListAssignedTicketsByVisibleProject
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListVisibleProjects $listVisibleProjects,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, array{project: array<string, mixed>, tickets: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function handle(User $viewer, User $assignee, ?Project $project = null): array
|
||||
{
|
||||
/** @var EloquentCollection<int, Project> $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<int, Collection<int, Ticket>> $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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Actions\Projects\ListVisibleProjects;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TicketResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ListTicketsByStatusByVisibleProject
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListVisibleProjects $listVisibleProjects,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param Collection<int, TicketStatus> $ticketStatuses
|
||||
* @return array<int, array{project: array<string, mixed>, tickets: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function handle(User $viewer, Collection $ticketStatuses, ?Project $project = null): array
|
||||
{
|
||||
/** @var EloquentCollection<int, Project> $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<int, Collection<int, Ticket>> $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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Actions\Notifications\BuildTicketStatusChangedPayload;
|
||||
use App\Actions\Notifications\TicketStatusChanged;
|
||||
use App\Enums\TicketWorklogEntryType;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class MoveProjectTicket
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildTicketStatusChangedPayload $buildTicketStatusChangedPayload,
|
||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{ticket_status_id: int} $validated
|
||||
* @return array{destination_label: string, moved: bool}
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): array
|
||||
{
|
||||
$ticketStatusId = (int) Arr::get($validated, 'ticket_status_id');
|
||||
|
||||
$destinationStatus = $project->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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Enums\TicketWorklogEntryType;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketLink;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreProjectTicketLink
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||
{
|
||||
$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);
|
||||
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Actions\Git\Data\GitBranch;
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
use App\Actions\Git\ValidateGitBranchName;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Models\UserGitConnection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Throwable;
|
||||
|
||||
class ValidateProjectTicketBranch
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
private readonly ValidateGitBranchName $validateGitBranchName,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{branch_name: string, repository_connection_id: int} $validated
|
||||
* @return array{is_valid: bool, message: string}
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): array
|
||||
{
|
||||
$repositoryConnectionId = (int) 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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\UserApplications;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserApplication;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CreateUserApplication
|
||||
{
|
||||
/**
|
||||
* @param array{name: string} $validated
|
||||
* @return array{application: UserApplication, plain_text_key: string}
|
||||
*/
|
||||
public function handle(User $user, array $validated): array
|
||||
{
|
||||
$plainTextKey = UserApplication::generatePlainTextKey();
|
||||
|
||||
$application = UserApplication::query()->create([
|
||||
'key_hash' => UserApplication::hashKey($plainTextKey),
|
||||
'name' => Arr::get($validated, 'name'),
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'application' => $application,
|
||||
'plain_text_key' => $plainTextKey,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\UserApplications;
|
||||
|
||||
use App\Models\UserApplication;
|
||||
|
||||
class DeleteUserApplication
|
||||
{
|
||||
public function handle(UserApplication $application): string
|
||||
{
|
||||
$name = $application->name;
|
||||
|
||||
$application->delete();
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\UserGitConnections;
|
||||
|
||||
use App\Models\UserGitConnection;
|
||||
|
||||
class DeleteUserGitConnection
|
||||
{
|
||||
public function handle(UserGitConnection $gitConnection): string
|
||||
{
|
||||
$label = "{$gitConnection->account_login} @ {$gitConnection->base_url}";
|
||||
|
||||
$gitConnection->delete();
|
||||
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\UserGitConnections;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
use App\Models\User;
|
||||
use App\Models\UserGitConnection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class StoreUserGitConnection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{api_token: string, base_url: string, provider: string} $validated
|
||||
*/
|
||||
public function handle(User $user, array $validated): UserGitConnection
|
||||
{
|
||||
$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) {
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\UserGitConnections;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
use App\Models\UserGitConnection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class UpdateUserGitConnection
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{api_token: string, base_url: string, provider: string} $validated
|
||||
*/
|
||||
public function handle(UserGitConnection $gitConnection, array $validated): UserGitConnection
|
||||
{
|
||||
$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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationChannel: string
|
||||
{
|
||||
case Discord = 'discord';
|
||||
case Webhook = 'webhook';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Discord => 'Discord',
|
||||
self::Webhook => 'Webhook',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $channel): array => [
|
||||
'label' => $channel->label(),
|
||||
'value' => $channel->value,
|
||||
],
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationDeliveryStatus: string
|
||||
{
|
||||
case Failed = 'failed';
|
||||
case Pending = 'pending';
|
||||
case Retryable = 'retryable';
|
||||
case Succeeded = 'succeeded';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Failed => 'Failed',
|
||||
self::Pending => 'Pending',
|
||||
self::Retryable => 'Retryable',
|
||||
self::Succeeded => 'Succeeded',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationEventType: string
|
||||
{
|
||||
case TicketStatusChanged = 'ticket_status_changed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TicketStatusChanged => 'Ticket status changed',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $eventType): array => [
|
||||
'label' => $eventType->label(),
|
||||
'value' => $eventType->value,
|
||||
],
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
abstract class ApiController extends Controller
|
||||
{
|
||||
protected function authenticatedUser(Request $request): User
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_if($user === null, 401);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\BuildProjectBacklogPayload;
|
||||
use App\Actions\Projects\BuildProjectBoardPayload;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProjectBoardController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
|
||||
private readonly BuildProjectBoardPayload $buildProjectBoardPayload,
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function board(Request $request, Project $project): JsonResponse
|
||||
{
|
||||
$user = $this->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
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\TicketStatusResource;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProjectBoardSettingsController extends ApiController
|
||||
{
|
||||
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): JsonResponse
|
||||
{
|
||||
$this->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
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;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class ProjectController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProject $createProject,
|
||||
private readonly DeleteProject $deleteProject,
|
||||
private readonly ListVisibleProjects $listVisibleProjects,
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
return ProjectResource::collection(
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
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\RepositoryConnectionResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProjectRepositorySettingsController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
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): JsonResponse
|
||||
{
|
||||
$user = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
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\Ticket;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProjectTicketBranchController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectTicketBranch $createProjectTicketBranch,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ValidateProjectTicketBranch $validateProjectTicketBranch,
|
||||
) {}
|
||||
|
||||
public function validate(
|
||||
ValidateProjectTicketBranchRequest $request,
|
||||
Project $project,
|
||||
Ticket $ticket,
|
||||
): JsonResponse {
|
||||
$user = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\BuildProjectTicketDetailPayload;
|
||||
use App\Actions\Projects\BuildProjectTicketOptionsPayload;
|
||||
use App\Actions\Projects\BuildProjectTicketStatusTargetsPayload;
|
||||
use App\Actions\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\MoveProjectTicket;
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\Api\MoveProjectTicketRequest;
|
||||
use App\Http\Requests\StoreProjectTicketRequest;
|
||||
use App\Http\Requests\UpdateProjectTicketRequest;
|
||||
use App\Http\Resources\TicketResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ProjectTicketController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectTicketDetailPayload $buildProjectTicketDetailPayload,
|
||||
private readonly BuildProjectTicketOptionsPayload $buildProjectTicketOptionsPayload,
|
||||
private readonly BuildProjectTicketStatusTargetsPayload $buildProjectTicketStatusTargetsPayload,
|
||||
private readonly CreateProjectTicket $createProjectTicket,
|
||||
private readonly MoveProjectTicket $moveProjectTicket,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Project $project): AnonymousResourceCollection
|
||||
{
|
||||
$user = $this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\DeleteProjectTicketLink;
|
||||
use App\Actions\Tickets\StoreProjectTicketLink;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\StoreTicketLinkRequest;
|
||||
use App\Http\Resources\TicketLinkResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketLink;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProjectTicketLinkController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DeleteProjectTicketLink $deleteProjectTicketLink,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly StoreProjectTicketLink $storeProjectTicketLink,
|
||||
) {}
|
||||
|
||||
public function store(
|
||||
StoreTicketLinkRequest $request,
|
||||
Project $project,
|
||||
Ticket $ticket,
|
||||
): JsonResponse {
|
||||
$user = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
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;
|
||||
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\Http\Requests\DestroyProjectTicketTypeRequest;
|
||||
use App\Http\Requests\StoreProjectNamedSettingRequest;
|
||||
use App\Http\Requests\StoreProjectTicketTypeRequest;
|
||||
use App\Http\Requests\StoreTicketFieldDefinitionRequest;
|
||||
use App\Http\Requests\UpdateProjectNamedSettingRequest;
|
||||
use App\Http\Requests\UpdateProjectTicketMetadataSettings as UpdateProjectTicketMetadataSettingsRequest;
|
||||
use App\Http\Requests\UpdateProjectTicketTypeRequest;
|
||||
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
|
||||
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\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ProjectTicketMetadataSettingsController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
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 UpdateProjectTicketMetadataSettings $updateProjectTicketMetadataSettings,
|
||||
private readonly UpdateProjectTicketPriority $updateProjectTicketPriority,
|
||||
private readonly UpdateProjectTicketType $updateProjectTicketType,
|
||||
private readonly UpdateTicketFieldDefinition $updateTicketFieldDefinition,
|
||||
private readonly UpdateTicketLinkType $updateTicketLinkType,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): JsonResponse
|
||||
{
|
||||
$this->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Settings;
|
||||
|
||||
use App\Actions\UserGitConnections\DeleteUserGitConnection;
|
||||
use App\Actions\UserGitConnections\StoreUserGitConnection;
|
||||
use App\Actions\UserGitConnections\UpdateUserGitConnection;
|
||||
use App\Http\Controllers\Api\ApiController;
|
||||
use App\Http\Requests\Settings\StoreUserGitConnectionRequest;
|
||||
use App\Http\Requests\Settings\UpdateUserGitConnectionRequest;
|
||||
use App\Http\Resources\UserGitConnectionResource;
|
||||
use App\Models\UserGitConnection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class GitConnectionController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DeleteUserGitConnection $deleteUserGitConnection,
|
||||
private readonly StoreUserGitConnection $storeUserGitConnection,
|
||||
private readonly UpdateUserGitConnection $updateUserGitConnection,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
return UserGitConnectionResource::collection(
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserAssignedTicketController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$viewer = $this->authenticatedUser($request);
|
||||
|
||||
$assignedTickets = $this->listAssignedTicketsByVisibleProject->handle($viewer, $user);
|
||||
|
||||
return response()->json([
|
||||
'data' => $assignedTickets,
|
||||
'meta' => [
|
||||
'assignee' => $assignedTickets === []
|
||||
? null
|
||||
: UserResource::make($user)->resolve(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, BoardColumn> $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<int, BoardColumn> $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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Notifications\BuildProjectNotificationSettingsPayload;
|
||||
use App\Actions\Notifications\DeleteProjectNotificationAction;
|
||||
use App\Actions\Notifications\StoreProjectNotificationAction;
|
||||
use App\Actions\Notifications\UpdateProjectNotificationAction;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\StoreProjectNotificationActionRequest;
|
||||
use App\Http\Requests\UpdateProjectNotificationActionRequest;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectNotificationSettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload,
|
||||
private readonly DeleteProjectNotificationAction $deleteProjectNotificationAction,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly StoreProjectNotificationAction $storeProjectNotificationAction,
|
||||
private readonly UpdateProjectNotificationAction $updateProjectNotificationAction,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
{
|
||||
$this->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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<int, array{
|
||||
* account_login: string,
|
||||
* account_name: string|null,
|
||||
* base_url: string,
|
||||
* error: string|null,
|
||||
* provider: string,
|
||||
* provider_label: string,
|
||||
* repositories: array<int, array{
|
||||
* full_name: string,
|
||||
* owner_name: string,
|
||||
* provider_repository_id: string,
|
||||
* repository_name: string,
|
||||
* repository_url: string,
|
||||
* visibility_label: string
|
||||
* }>,
|
||||
* user_git_connection_id: int
|
||||
* }>
|
||||
*/
|
||||
private function availableRepositorySources(User $user): array
|
||||
{
|
||||
/** @var Collection<int, UserGitConnection> $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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, Model> $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<int, Model> $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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Actions\UserApplications\CreateUserApplication;
|
||||
use App\Actions\UserApplications\DeleteUserApplication;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\StoreUserApplicationRequest;
|
||||
use App\Http\Resources\UserApplicationResource;
|
||||
use App\Models\UserApplication;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class UserApplicationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateUserApplication $createUserApplication,
|
||||
private readonly DeleteUserApplication $deleteUserApplication,
|
||||
) {}
|
||||
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
return Inertia::render('settings/Applications', [
|
||||
'applicationKey' => $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\UserApplication;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateApiApplicationKey
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$plainTextKey = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class MoveProjectTicketRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|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<int, callable(Validator): void>
|
||||
*/
|
||||
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.',
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class StoreUserApplicationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreProjectNotificationActionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|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<int, string>
|
||||
*/
|
||||
private function channels(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (NotificationChannel $channel): string => $channel->value,
|
||||
NotificationChannel::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function eventTypes(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (NotificationEventType $eventType): string => $eventType->value,
|
||||
NotificationEventType::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ValidationRule|string>
|
||||
*/
|
||||
private function messageTemplateRules(): array
|
||||
{
|
||||
if ($this->input('channel') === NotificationChannel::Discord->value) {
|
||||
return ['required', 'string', 'max:2000'];
|
||||
}
|
||||
|
||||
return ['nullable', 'string', 'max:2000'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectNotificationActionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|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<int, string>
|
||||
*/
|
||||
private function channels(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (NotificationChannel $channel): string => $channel->value,
|
||||
NotificationChannel::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function eventTypes(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (NotificationEventType $eventType): string => $eventType->value,
|
||||
NotificationEventType::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ValidationRule|string>
|
||||
*/
|
||||
private function messageTemplateRules(): array
|
||||
{
|
||||
if ($this->input('channel') === NotificationChannel::Discord->value) {
|
||||
return ['required', 'string', 'max:2000'];
|
||||
}
|
||||
|
||||
return ['nullable', 'string', 'max:2000'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\NotificationAction;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class NotificationActionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class NotificationDeliveryLogResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserApplicationResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||
use App\Mcp\Tools\ListTicketsByStatus;
|
||||
use App\Mcp\Tools\MoveTicketStatus;
|
||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
use Laravel\Mcp\Server;
|
||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Attributes\Version;
|
||||
|
||||
#[Name('KanbanGit MCP')]
|
||||
#[Version('0.1.0')]
|
||||
#[Instructions('Use these tools to read and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially ticket_id and ticket_status_id.')]
|
||||
class KanbanGitServer extends Server
|
||||
{
|
||||
protected array $tools = [
|
||||
ListMyAssignedTickets::class,
|
||||
ListTicketsByStatus::class,
|
||||
GetTicket::class,
|
||||
UpdateTicketDescription::class,
|
||||
UpdateTicketAssignee::class,
|
||||
MoveTicketStatus::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
//
|
||||
];
|
||||
|
||||
protected array $prompts = [
|
||||
//
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TicketResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
|
||||
class KanbanGitMcp
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws AuthenticationException
|
||||
*/
|
||||
public function user(Request $request): User
|
||||
{
|
||||
$user = $request->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<string, mixed> $validated
|
||||
* @return Collection<int, TicketStatus>|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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[Name('get_ticket')]
|
||||
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||
#[IsReadOnly]
|
||||
class GetTicket extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->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<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'ticket_id' => $schema->integer()
|
||||
->description('Ticket ID.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[Name('list_my_assigned_tickets')]
|
||||
#[Description('List tickets assigned to the authenticated application key owner, grouped by visible project. Optionally filter to one project.')]
|
||||
#[IsReadOnly]
|
||||
class ListMyAssignedTickets extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\ListTicketsByStatusByVisibleProject;
|
||||
use App\Http\Resources\TicketStatusResource;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[Name('list_tickets_by_status')]
|
||||
#[Description('List tickets in a status, grouped by visible project. Use ticket_status_id when possible; status_slug or status_name are convenience lookups and must be unambiguous.')]
|
||||
#[IsReadOnly]
|
||||
class ListTicketsByStatus extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly ListTicketsByStatusByVisibleProject $listTicketsByStatusByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\MoveProjectTicket;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[Name('move_ticket_status')]
|
||||
#[Description('Move a ticket to an allowed target status. Use ticket_status_id when possible; status_slug or status_name are convenience lookups and must resolve to one status in the ticket project.')]
|
||||
#[IsDestructive(false)]
|
||||
class MoveTicketStatus extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly MoveProjectTicket $moveProjectTicket,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->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<string, Type>
|
||||
*/
|
||||
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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user