Add application API and OpenAPI docs
This commit is contained in:
@@ -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,67 @@
|
||||
<?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()
|
||||
->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,124 @@
|
||||
<?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()
|
||||
->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,46 @@
|
||||
<?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()
|
||||
->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,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets;
|
||||
|
||||
use App\Enums\TicketWorklogEntryType;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class MoveProjectTicket
|
||||
{
|
||||
public function __construct(
|
||||
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,
|
||||
]);
|
||||
|
||||
$this->createTicketWorklogEntry->handle(
|
||||
$ticket->fresh(),
|
||||
$user,
|
||||
TicketWorklogEntryType::StatusChanged,
|
||||
"Moved from {$originLabel} to {$destinationLabel}.",
|
||||
[
|
||||
'from' => [
|
||||
'label' => $originLabel,
|
||||
'ticket_status_id' => $originStatusId,
|
||||
],
|
||||
'to' => [
|
||||
'label' => $destinationLabel,
|
||||
'ticket_status_id' => $destinationStatus->id,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return [
|
||||
'destination_label' => $destinationLabel,
|
||||
'moved' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function statusLabel(?TicketStatus $ticketStatus): string
|
||||
{
|
||||
if ($ticketStatus === null) {
|
||||
return 'Unknown status';
|
||||
}
|
||||
|
||||
if ($ticketStatus->boardColumn === null) {
|
||||
return $ticketStatus->name;
|
||||
}
|
||||
|
||||
if ($ticketStatus->boardColumn->name === $ticketStatus->name) {
|
||||
return $ticketStatus->name;
|
||||
}
|
||||
|
||||
return "{$ticketStatus->boardColumn->name} / {$ticketStatus->name}";
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user