Add application API and OpenAPI docs
linter / quality (pull_request) Successful in 1m18s
tests / ci (8.4) (pull_request) Successful in 1m28s

This commit is contained in:
2026-04-20 13:22:56 +02:00
parent 70d760e3a6
commit 8b25421e7c
80 changed files with 6908 additions and 1277 deletions
@@ -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();
}
}