Merge pull request 'codex/api-application-access' (#52) from codex/api-application-access into main
linter / quality (push) Successful in 1m15s
tests / ci (8.4) (push) Successful in 1m32s

Reviewed-on: OpenSource/KanbanGit#52
This commit was merged in pull request #52.
This commit is contained in:
2026-04-24 08:32:36 +00:00
81 changed files with 7060 additions and 1277 deletions
@@ -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(),
];
}
}
+30
View File
@@ -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;
}
}
+17
View File
@@ -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;
}
}
+86
View File
@@ -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();
}
}
@@ -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);
}
}
+12 -79
View File
@@ -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),
);
}
}
+15 -68
View File
@@ -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.");
@@ -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();
}
}
+16 -130
View File
@@ -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,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.',
);
}
},
];
}
}
+21
View File
@@ -3,9 +3,12 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -69,4 +72,22 @@ class User extends Authenticatable
{
return $this->hasMany(Ticket::class, 'author_user_id');
}
public function scopeVisibleToProject(Builder $query, Project $project): Builder
{
$permissionService = app(PermissionService::class);
$allowedUserIds = User::query()
->select('id')
->cursor()
->filter(fn (User $candidate) => $permissionService->allows(
$candidate,
Permission::ProjectRead,
$project
))
->pluck('id')
->all();
return $query->whereKey($allowedUserIds);
}
}
+2 -1
View File
@@ -9,8 +9,9 @@ use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
api: __DIR__.'/../routes/api.php',
web: __DIR__.'/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<?php
require __DIR__.'/api/projects.php';
require __DIR__.'/api/settings.php';
+34
View File
@@ -0,0 +1,34 @@
<?php
use App\Http\Controllers\Api\ProjectBoardSettingsController;
use App\Http\Controllers\Api\ProjectRepositorySettingsController;
use App\Http\Controllers\Api\ProjectTicketMetadataSettingsController;
use Illuminate\Support\Facades\Route;
Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board');
Route::prefix('board')
->name('board.')
->controller(ProjectBoardSettingsController::class)
->group(function (): void {
require __DIR__.'/settings/board.php';
});
Route::get('metadata', [ProjectTicketMetadataSettingsController::class, 'show'])->name('metadata');
Route::patch('metadata', [ProjectTicketMetadataSettingsController::class, 'update'])->name('metadata.update');
Route::prefix('metadata')
->name('metadata.')
->controller(ProjectTicketMetadataSettingsController::class)
->group(function (): void {
require __DIR__.'/settings/metadata.php';
});
Route::get('repositories', [ProjectRepositorySettingsController::class, 'show'])->name('repositories');
Route::prefix('repositories')
->name('repositories.')
->controller(ProjectRepositorySettingsController::class)
->group(function (): void {
require __DIR__.'/settings/repositories.php';
});
+16
View File
@@ -0,0 +1,16 @@
<?php
use Illuminate\Support\Facades\Route;
Route::prefix('columns')->name('columns.')->group(function (): void {
Route::post('/', 'storeColumn')->name('store');
Route::patch('{boardColumn}', 'updateColumn')->name('update');
Route::delete('{boardColumn}', 'destroyColumn')->name('destroy');
});
Route::prefix('statuses')->name('statuses.')->group(function (): void {
Route::post('/', 'storeStatus')->name('store');
Route::patch('{ticketStatus}', 'updateStatus')->name('update');
Route::delete('{ticketStatus}', 'destroyStatus')->name('destroy');
Route::patch('{ticketStatus}/transitions', 'updateStatusTransitions')->name('transitions.update');
});
+27
View File
@@ -0,0 +1,27 @@
<?php
use Illuminate\Support\Facades\Route;
Route::prefix('types')->name('types.')->group(function (): void {
Route::post('/', 'storeType')->name('store');
Route::patch('{ticketType}', 'updateType')->name('update');
Route::delete('{ticketType}', 'destroyType')->name('destroy');
});
Route::prefix('priorities')->name('priorities.')->group(function (): void {
Route::post('/', 'storePriority')->name('store');
Route::patch('{ticketPriority}', 'updatePriority')->name('update');
Route::delete('{ticketPriority}', 'destroyPriority')->name('destroy');
});
Route::prefix('fields')->name('fields.')->group(function (): void {
Route::post('/', 'storeFieldDefinition')->name('store');
Route::patch('{ticketFieldDefinition}', 'updateFieldDefinition')->name('update');
Route::delete('{ticketFieldDefinition}', 'destroyFieldDefinition')->name('destroy');
});
Route::prefix('link-types')->name('link-types.')->group(function (): void {
Route::post('/', 'storeLinkType')->name('store');
Route::patch('{ticketLinkType}', 'updateLinkType')->name('update');
Route::delete('{ticketLinkType}', 'destroyLinkType')->name('destroy');
});
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
Route::post('/', 'store')->name('store');
Route::patch('{repositoryConnection}', 'update')->name('update');
Route::delete('{repositoryConnection}', 'destroy')->name('destroy');
+31
View File
@@ -0,0 +1,31 @@
<?php
use App\Http\Controllers\Api\ProjectTicketBranchController;
use App\Http\Controllers\Api\ProjectTicketController;
use App\Http\Controllers\Api\ProjectTicketLinkController;
use Illuminate\Support\Facades\Route;
Route::prefix('{ticket}')->group(function (): void {
Route::get('/', [ProjectTicketController::class, 'show'])->name('show');
Route::patch('/', [ProjectTicketController::class, 'update'])->name('update');
Route::get('status-targets', [ProjectTicketController::class, 'statusTargets'])->name('status-targets');
Route::patch('status', [ProjectTicketController::class, 'move'])->name('status.update');
Route::prefix('branches')
->name('branches.')
->controller(ProjectTicketBranchController::class)
->group(function (): void {
Route::get('validate', 'validate')->name('validate');
Route::post('/', 'store')->name('store');
});
Route::prefix('links')
->name('links.')
->controller(ProjectTicketLinkController::class)
->group(function (): void {
Route::post('/', 'store')->name('store');
Route::delete('{ticketLink}', 'destroy')
->withoutScopedBindings()
->name('destroy');
});
});
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Http\Controllers\Api\ProjectBoardController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ProjectTicketController;
use Illuminate\Support\Facades\Route;
Route::prefix('projects')
->name('api.projects.')
->group(function (): void {
Route::get('/', [ProjectController::class, 'index'])->name('index');
Route::post('/', [ProjectController::class, 'store'])->name('store');
Route::prefix('{project}')
->group(function (): void {
Route::get('/', [ProjectController::class, 'show'])->name('show');
Route::delete('/', [ProjectController::class, 'destroy'])->name('destroy');
Route::get('board', [ProjectBoardController::class, 'board'])->name('board');
Route::get('backlog', [ProjectBoardController::class, 'backlog'])->name('backlog');
Route::get('ticket-options', [ProjectTicketController::class, 'options'])->name('ticket-options');
Route::get('tickets', [ProjectTicketController::class, 'index'])->name('tickets.index');
Route::post('tickets', [ProjectTicketController::class, 'store'])->name('tickets.store');
Route::scopeBindings()->group(function (): void {
Route::prefix('tickets')
->name('tickets.')
->group(function (): void {
require __DIR__.'/project/tickets.php';
});
Route::prefix('settings')
->name('settings.')
->group(function (): void {
require __DIR__.'/project/settings.php';
});
});
});
});
+14
View File
@@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\Api\Settings\GitConnectionController;
use Illuminate\Support\Facades\Route;
Route::prefix('git-connections')
->name('api.git-connections.')
->controller(GitConnectionController::class)
->group(function (): void {
Route::get('/', 'index')->name('index');
Route::post('/', 'store')->name('store');
Route::patch('{gitConnection}', 'update')->name('update');
Route::delete('{gitConnection}', 'destroy')->name('destroy');
});
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
class OpenApiDocumentationTest extends TestCase
{
public function test_openapi_document_is_valid_json_and_covers_application_api_routes(): void
{
$documentPath = base_path('docs/openapi/kanbangit-api.openapi.json');
$this->assertFileExists($documentPath);
$document = json_decode((string) file_get_contents($documentPath), true);
$this->assertIsArray($document);
$this->assertSame(JSON_ERROR_NONE, json_last_error());
$this->assertSame('3.1.0', $document['openapi']);
$this->assertSame([], $document['security']);
$expectedOperations = [
['get', '/projects'],
['post', '/projects'],
['get', '/projects/{project}'],
['delete', '/projects/{project}'],
['get', '/projects/{project}/board'],
['get', '/projects/{project}/backlog'],
['get', '/projects/{project}/ticket-options'],
['get', '/projects/{project}/tickets'],
['post', '/projects/{project}/tickets'],
['get', '/projects/{project}/tickets/{ticket}'],
['patch', '/projects/{project}/tickets/{ticket}'],
['get', '/projects/{project}/tickets/{ticket}/status-targets'],
['patch', '/projects/{project}/tickets/{ticket}/status'],
['get', '/projects/{project}/tickets/{ticket}/branches/validate'],
['post', '/projects/{project}/tickets/{ticket}/branches'],
['post', '/projects/{project}/tickets/{ticket}/links'],
['delete', '/projects/{project}/tickets/{ticket}/links/{ticketLink}'],
['get', '/projects/{project}/settings/board'],
['post', '/projects/{project}/settings/board/columns'],
['patch', '/projects/{project}/settings/board/columns/{boardColumn}'],
['delete', '/projects/{project}/settings/board/columns/{boardColumn}'],
['post', '/projects/{project}/settings/board/statuses'],
['patch', '/projects/{project}/settings/board/statuses/{ticketStatus}'],
['delete', '/projects/{project}/settings/board/statuses/{ticketStatus}'],
['patch', '/projects/{project}/settings/board/statuses/{ticketStatus}/transitions'],
['get', '/projects/{project}/settings/metadata'],
['patch', '/projects/{project}/settings/metadata'],
['post', '/projects/{project}/settings/metadata/types'],
['patch', '/projects/{project}/settings/metadata/types/{ticketType}'],
['delete', '/projects/{project}/settings/metadata/types/{ticketType}'],
['post', '/projects/{project}/settings/metadata/priorities'],
['patch', '/projects/{project}/settings/metadata/priorities/{ticketPriority}'],
['delete', '/projects/{project}/settings/metadata/priorities/{ticketPriority}'],
['post', '/projects/{project}/settings/metadata/fields'],
['patch', '/projects/{project}/settings/metadata/fields/{ticketFieldDefinition}'],
['delete', '/projects/{project}/settings/metadata/fields/{ticketFieldDefinition}'],
['post', '/projects/{project}/settings/metadata/link-types'],
['patch', '/projects/{project}/settings/metadata/link-types/{ticketLinkType}'],
['delete', '/projects/{project}/settings/metadata/link-types/{ticketLinkType}'],
['get', '/projects/{project}/settings/repositories'],
['post', '/projects/{project}/settings/repositories'],
['patch', '/projects/{project}/settings/repositories/{repositoryConnection}'],
['delete', '/projects/{project}/settings/repositories/{repositoryConnection}'],
['get', '/git-connections'],
['post', '/git-connections'],
['patch', '/git-connections/{gitConnection}'],
['delete', '/git-connections/{gitConnection}'],
];
foreach ($expectedOperations as [$method, $path]) {
$this->assertArrayHasKey($path, $document['paths']);
$this->assertArrayHasKey($method, $document['paths'][$path]);
}
$this->assertArrayHasKey('ValidationError', $document['components']['schemas']);
$this->assertArrayHasKey('TicketUpdateRequest', $document['components']['schemas']);
$this->assertArrayHasKey('UserGitConnectionRequest', $document['components']['schemas']);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature\Api;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectApiTest extends TestCase
{
use RefreshDatabase;
public function test_users_can_list_only_visible_projects(): void
{
$user = User::factory()->create();
$visibleProject = Project::factory()->create([
'name' => 'Visible project',
]);
Project::factory()->create([
'name' => 'Hidden project',
]);
$this->syncProjectPermissions($visibleProject, $user, ['view']);
$this->actingAs($user)
->getJson(route('api.projects.index'))
->assertOk()
->assertJsonPath('data.0.name', 'Visible project')
->assertJsonCount(1, 'data');
}
public function test_users_with_project_create_permission_can_create_projects(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)
->postJson(route('api.projects.store'), [
'key' => 'api',
'name' => 'API Project',
])
->assertCreated()
->assertJsonPath('data.key', 'API')
->assertJsonPath('data.name', 'API Project')
->assertJsonPath('meta.message', 'API Project was created.');
$project = Project::query()->where('key', 'API')->firstOrFail();
$this->assertSame(2, $project->ticketTypes()->count());
$this->assertSame(1, $project->ticketStatuses()->count());
}
public function test_project_show_and_delete_enforce_permissions(): void
{
$user = User::factory()->create();
$admin = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->actingAs($user)
->getJson(route('api.projects.show', $project))
->assertForbidden();
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->getJson(route('api.projects.show', $project))
->assertOk()
->assertJsonPath('data.key', 'CORE');
$this->syncGlobalPermissions($admin, ['admin']);
$this->actingAs($admin)
->deleteJson(route('api.projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertNoContent();
$this->assertDatabaseMissing('projects', [
'id' => $project->id,
]);
}
}
+271
View File
@@ -0,0 +1,271 @@
<?php
namespace Tests\Feature\Api;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class SettingsApiTest extends TestCase
{
use RefreshDatabase;
public function test_board_settings_can_be_read_and_mutated(): void
{
[$user, $project] = $this->projectContext();
$this->actingAs($user)
->getJson(route('api.projects.settings.board', $project))
->assertOk()
->assertJsonPath('data.backlogTicketStatuses.0.name', 'Backlog');
$columnId = $this->actingAs($user)
->postJson(route('api.projects.settings.board.columns.store', $project), [
'name' => 'In Review',
])
->assertCreated()
->assertJsonPath('data.name', 'In Review')
->json('data.id');
$this->actingAs($user)
->patchJson(route('api.projects.settings.board.columns.update', [$project, $columnId]), [
'name' => 'Review',
'position' => 1,
])
->assertOk()
->assertJsonPath('data.name', 'Review');
$statusId = $this->actingAs($user)
->postJson(route('api.projects.settings.board.statuses.store', $project), [
'board_column_id' => $columnId,
'name' => 'Ready',
])
->assertCreated()
->assertJsonPath('data.name', 'Ready')
->json('data.id');
$replacementStatusId = $this->actingAs($user)
->postJson(route('api.projects.settings.board.statuses.store', $project), [
'board_column_id' => $columnId,
'name' => 'QA',
])
->assertCreated()
->json('data.id');
$backlogStatus = $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail();
$this->actingAs($user)
->patchJson(route('api.projects.settings.board.statuses.transitions.update', [$project, $backlogStatus]), [
'allowed_ticket_status_ids' => [$statusId],
])
->assertOk()
->assertJsonPath('data.allowed_target_statuses.0.id', $statusId);
$this->actingAs($user)
->deleteJson(route('api.projects.settings.board.statuses.destroy', [$project, $statusId]))
->assertNoContent();
$this->assertDatabaseHas('ticket_statuses', [
'id' => $replacementStatusId,
]);
$this->actingAs($user)
->deleteJson(route('api.projects.settings.board.columns.destroy', [$project, $columnId]))
->assertNoContent();
}
public function test_metadata_settings_can_be_read_and_mutated(): void
{
[$user, $project] = $this->projectContext();
$this->actingAs($user)
->getJson(route('api.projects.settings.metadata', $project))
->assertOk()
->assertJsonPath('data.ticketTypes.0.name', 'Task');
$this->actingAs($user)
->patchJson(route('api.projects.settings.metadata.update', $project), [
'show_author' => false,
'show_story_points' => false,
])
->assertOk()
->assertJsonPath('data.show_author', false);
$typeId = $this->actingAs($user)
->postJson(route('api.projects.settings.metadata.types.store', $project), [
'is_parent' => true,
'name' => 'Epic',
])
->assertCreated()
->assertJsonPath('data.name', 'Epic')
->json('data.id');
$this->actingAs($user)
->patchJson(route('api.projects.settings.metadata.types.update', [$project, $typeId]), [
'name' => 'Initiative',
'position' => 1,
])
->assertOk()
->assertJsonPath('data.name', 'Initiative');
$priorityId = $this->actingAs($user)
->postJson(route('api.projects.settings.metadata.priorities.store', $project), [
'name' => 'Urgent',
])
->assertCreated()
->json('data.id');
$fieldId = $this->actingAs($user)
->postJson(route('api.projects.settings.metadata.fields.store', $project), [
'field_type' => 'number',
'label' => 'Risk',
])
->assertCreated()
->json('data.id');
$linkTypeId = $this->actingAs($user)
->postJson(route('api.projects.settings.metadata.link-types.store', $project), [
'name' => 'Relates to',
])
->assertCreated()
->json('data.id');
$this->actingAs($user)
->deleteJson(route('api.projects.settings.metadata.fields.destroy', [$project, $fieldId]))
->assertNoContent();
$this->actingAs($user)
->deleteJson(route('api.projects.settings.metadata.link-types.destroy', [$project, $linkTypeId]))
->assertNoContent();
$this->actingAs($user)
->deleteJson(route('api.projects.settings.metadata.priorities.destroy', [$project, $priorityId]))
->assertNoContent();
$this->actingAs($user)
->deleteJson(route('api.projects.settings.metadata.types.destroy', [$project, $typeId]))
->assertNoContent();
}
public function test_project_payloads_only_return_users_who_can_view_the_project(): void
{
[$user, $project] = $this->projectContext();
$viewer = User::factory()->create(['name' => 'Visible User']);
User::factory()->create(['name' => 'Hidden User']);
$teamViewer = User::factory()->create(['name' => 'Team Viewer']);
$team = Team::factory()->create();
$this->syncProjectPermissions($project, $viewer, ['view']);
$team->users()->attach($teamViewer);
$this->syncProjectPermissions($project, $team, ['view']);
$ticket = $project->tickets()->firstOrCreate([
'sequence_number' => 1,
], [
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'title' => 'Scoped users ticket',
]);
$this->actingAs($user)
->getJson(route('api.projects.backlog', $project))
->assertOk()
->assertJsonPath('data.users.0.name', $user->name)
->assertJsonPath('data.users.1.name', 'Team Viewer')
->assertJsonPath('data.users.2.name', 'Visible User')
->assertJsonCount(3, 'data.users');
$this->actingAs($user)
->getJson(route('api.projects.tickets.show', [$project, $ticket]))
->assertOk()
->assertJsonPath('data.users.0.name', $user->name)
->assertJsonPath('data.users.1.name', 'Team Viewer')
->assertJsonPath('data.users.2.name', 'Visible User')
->assertJsonCount(3, 'data.users');
}
public function test_project_repositories_and_user_git_connections_are_available(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/user' => Http::response([
'id' => 101,
'login' => 'api-user',
'full_name' => 'API User',
]),
'https://git.example.test/api/v1/user/repos*' => Http::response([
[
'id' => 501,
'name' => 'kanbangit',
'full_name' => 'core-team/kanbangit',
'html_url' => 'https://git.example.test/core-team/kanbangit',
'owner' => [
'login' => 'core-team',
],
'private' => true,
],
]),
]);
[$user, $project] = $this->projectContext();
$gitConnectionId = $this->actingAs($user)
->postJson(route('api.git-connections.store'), [
'api_token' => 'secret-token',
'base_url' => 'https://git.example.test/api/v1',
'provider' => 'gitea',
])
->assertCreated()
->assertJsonPath('data.account_login', 'api-user')
->json('data.id');
$this->actingAs($user)
->getJson(route('api.git-connections.index'))
->assertOk()
->assertJsonPath('data.0.id', $gitConnectionId);
$repositoryConnectionId = $this->actingAs($user)
->postJson(route('api.projects.settings.repositories.store', $project), [
'provider_repository_id' => '501',
'user_git_connection_id' => $gitConnectionId,
])
->assertCreated()
->assertJsonPath('data.repository_full_name', 'core-team/kanbangit')
->json('data.id');
$this->actingAs($user)
->getJson(route('api.projects.settings.repositories', $project))
->assertOk()
->assertJsonPath('data.repositoryConnections.0.id', $repositoryConnectionId)
->assertJsonPath('data.availableRepositorySources.0.repositories.0.full_name', 'core-team/kanbangit');
$this->actingAs($user)
->deleteJson(route('api.projects.settings.repositories.destroy', [$project, $repositoryConnectionId]))
->assertNoContent();
$this->actingAs($user)
->deleteJson(route('api.git-connections.destroy', $gitConnectionId))
->assertNoContent();
}
/**
* @return array{0: User, 1: Project}
*/
private function projectContext(): array
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
return [$user, $project];
}
}
+335
View File
@@ -0,0 +1,335 @@
<?php
namespace Tests\Feature\Api;
use App\Actions\Git\GitProviderKey;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
use App\Models\TicketLinkType;
use App\Models\TicketStatus;
use App\Models\User;
use App\Models\UserGitConnection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class TicketApiTest extends TestCase
{
use RefreshDatabase;
public function test_ticket_read_and_write_api_flows(): void
{
[$user, $project] = $this->projectContext();
$priority = $project->ticketPriorities()->firstOrFail();
$status = $project->ticketStatuses()->firstOrFail();
$type = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
$this->actingAs($user)
->getJson(route('api.projects.ticket-options', [
'project' => $project,
'source' => 'backlog',
]))
->assertOk()
->assertJsonPath('data.defaultTicketStatusId', $status->id)
->assertJsonPath('data.ticketTypes.0.id', $type->id);
$createResponse = $this->actingAs($user)
->postJson(route('api.projects.tickets.store', $project), [
'acceptance_criteria' => 'Can sign in',
'description' => 'Build the API-created ticket',
'story_points' => 3,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $status->id,
'ticket_type_id' => $type->id,
'title' => 'API ticket',
])
->assertCreated()
->assertJsonPath('data.ticket_key', 'CORE-1')
->assertJsonPath('meta.message', 'CORE-1 was created.');
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
$this->actingAs($user)
->getJson(route('api.projects.tickets.index', $project))
->assertOk()
->assertJsonPath('data.0.ticket_key', 'CORE-1');
$this->actingAs($user)
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
'acceptance_criteria' => 'Can sign in from the API',
'blocked_comment' => null,
'description' => 'Updated through the API',
'is_blocked' => false,
'parent_ticket_id' => null,
'story_points' => 5,
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $type->id,
'title' => 'Updated API ticket',
])
->assertOk()
->assertJsonPath('data.title', 'Updated API ticket')
->assertJsonPath('meta.message', 'CORE-1 was updated.');
$this->actingAs($user)
->getJson(route('api.projects.tickets.show', [$project, $ticket]))
->assertOk()
->assertJsonPath('data.ticket.ticket_key', 'CORE-1')
->assertJsonPath('data.ticket.story_points', 5);
}
public function test_ticket_update_hides_author_for_non_admins_when_author_visibility_is_disabled(): void
{
$writer = User::factory()->create();
$author = User::factory()->create([
'name' => 'Author User',
]);
$project = Project::factory()->create([
'key' => 'CORE',
'show_author' => false,
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$status = $project->ticketStatuses()->firstOrFail();
$type = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
$ticket = Ticket::factory()->create([
'author_user_id' => $author->id,
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $status->id,
'ticket_type_id' => $type->id,
'title' => 'API ticket',
]);
$this->syncProjectPermissions($project, $writer, ['write', 'view']);
$this->actingAs($writer)
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
'acceptance_criteria' => $ticket->acceptance_criteria,
'blocked_comment' => $ticket->blocked_comment,
'description' => $ticket->description,
'is_blocked' => $ticket->is_blocked,
'parent_ticket_id' => $ticket->parent_ticket_id,
'story_points' => $ticket->story_points,
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $type->id,
'title' => 'Updated API ticket',
])
->assertOk()
->assertJsonPath('data.author', null)
->assertJsonPath('data.author_user_id', null);
}
public function test_ticket_options_only_return_project_visible_users(): void
{
[$user, $project] = $this->projectContext();
$projectViewer = User::factory()->create();
$outsider = User::factory()->create();
$this->syncProjectPermissions($project, $projectViewer, ['view']);
$response = $this->actingAs($user)
->getJson(route('api.projects.ticket-options', [
'project' => $project,
'source' => 'backlog',
]))
->assertOk();
$userIds = collect($response->json('data.users'))
->pluck('id')
->all();
$this->assertContains($user->id, $userIds);
$this->assertContains($projectViewer->id, $userIds);
$this->assertNotContains($outsider->id, $userIds);
}
public function test_tickets_can_return_status_targets_and_move_statuses(): void
{
[$user, $project] = $this->projectContext();
$backlogStatus = $project->ticketStatuses()->firstOrFail();
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Doing',
'position' => 1,
]);
$doingStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Doing',
'position' => 1,
]);
$backlogStatus->allowedTargetStatuses()->sync([$doingStatus->id]);
$ticket = $this->ticket($project, $backlogStatus);
$this->actingAs($user)
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
->assertOk()
->assertJsonPath('data.current_status.id', $backlogStatus->id)
->assertJsonPath('data.target_statuses.0.id', $doingStatus->id);
$this->actingAs($user)
->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [
'ticket_status_id' => $doingStatus->id,
])
->assertOk()
->assertJsonPath('data.ticket_status_id', $doingStatus->id)
->assertJsonPath('meta.message', 'CORE-1 moved to Doing.');
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id,
'actor_id' => $user->id,
'entry_type' => 'status_changed',
]);
}
public function test_ticket_links_and_branch_endpoints_are_available(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::sequence()
->push([
['name' => 'main'],
], 200)
->push([
['name' => 'main'],
], 200)
->push([
'name' => 'core-1-api-ticket',
], 201),
'https://git.example.test/api/v1/repos/core-team/kanbangit' => Http::response([
'id' => 501,
'name' => 'kanbangit',
'full_name' => 'core-team/kanbangit',
'html_url' => 'https://git.example.test/core-team/kanbangit',
'default_branch' => 'main',
'owner' => [
'login' => 'core-team',
],
'private' => false,
]),
]);
[$user, $project] = $this->projectContext();
$ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail());
$linkedTicket = $this->ticket($project, $project->ticketStatuses()->firstOrFail(), 2);
$linkType = TicketLinkType::factory()->for($project)->create([
'label' => 'Relates to',
'position' => 1,
]);
$this->actingAs($user)
->postJson(route('api.projects.tickets.links.store', [$project, $ticket]), [
'linked_ticket_id' => $linkedTicket->id,
'ticket_link_type_id' => $linkType->id,
])
->assertCreated()
->assertJsonPath('data.ticket_link_type.label', 'Relates to')
->assertJsonPath('meta.message', 'Relates to link was saved.');
$repositoryConnection = RepositoryConnection::factory()->for($project)->create([
'base_url' => 'https://git.example.test',
'owner_name' => 'core-team',
'provider' => GitProviderKey::Gitea,
'provider_repository_id' => '501',
'repository_full_name' => 'core-team/kanbangit',
'repository_name' => 'kanbangit',
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
UserGitConnection::factory()->for($user)->create([
'api_token' => 'branch-token',
'base_url' => 'https://git.example.test',
'provider' => GitProviderKey::Gitea,
]);
$this->actingAs($user)
->getJson(route('api.projects.tickets.branches.validate', [
'project' => $project,
'ticket' => $ticket,
'branch_name' => 'core-1-api-ticket',
'repository_connection_id' => $repositoryConnection->id,
]))
->assertOk()
->assertJsonPath('data.is_valid', true);
$this->actingAs($user)
->postJson(route('api.projects.tickets.branches.store', [$project, $ticket]), [
'branch_name' => 'core-1-api-ticket',
'repository_connection_id' => $repositoryConnection->id,
])
->assertCreated()
->assertJsonPath('data.branch_name', 'core-1-api-ticket')
->assertJsonPath('meta.message', 'core-1-api-ticket was created in core-team/kanbangit.');
}
public function test_board_and_backlog_payloads_are_returned(): void
{
[$user, $project] = $this->projectContext();
$backlogStatus = $project->ticketStatuses()->firstOrFail();
$ticket = $this->ticket($project, $backlogStatus);
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
]);
TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Todo',
'position' => 1,
]);
$this->actingAs($user)
->getJson(route('api.projects.backlog', $project))
->assertOk()
->assertJsonPath('data.tickets.0.id', $ticket->id);
$this->actingAs($user)
->getJson(route('api.projects.board', $project))
->assertOk()
->assertJsonPath('data.boardColumns.0.name', 'Todo');
}
/**
* @return array{0: User, 1: Project}
*/
private function projectContext(): array
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
return [$user, $project];
}
private function ticket(Project $project, TicketStatus $ticketStatus, int $sequenceNumber = 1): Ticket
{
$priority = $project->ticketPriorities()->firstOrFail();
$type = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
return Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => $sequenceNumber,
'ticket_key' => "CORE-{$sequenceNumber}",
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $ticketStatus->id,
'ticket_type_id' => $type->id,
'title' => 'API ticket',
]);
}
}