diff --git a/app/Actions/Git/Contracts/GitProviderClient.php b/app/Actions/Git/Contracts/GitProviderClient.php index 9153465..a95c970 100644 --- a/app/Actions/Git/Contracts/GitProviderClient.php +++ b/app/Actions/Git/Contracts/GitProviderClient.php @@ -30,20 +30,16 @@ interface GitProviderClient * @return array */ public function listBranches( - string $baseUrl, string $token, - string $owner, - string $repository, + GitRepository $repository, ): array; /** * @return array */ public function listPullRequests( - string $baseUrl, string $token, - string $owner, - string $repository, + GitRepository $repository, ): array; public function createBranch( diff --git a/app/Actions/Git/Data/GitRepository.php b/app/Actions/Git/Data/GitRepository.php index 98696fc..e244462 100644 --- a/app/Actions/Git/Data/GitRepository.php +++ b/app/Actions/Git/Data/GitRepository.php @@ -8,6 +8,7 @@ namespace App\Actions\Git\Data; final readonly class GitRepository { public function __construct( + public string $baseUrl, public string $providerRepositoryId, public string $owner, public string $name, diff --git a/app/Actions/Git/Gitea/GiteaProviderClient.php b/app/Actions/Git/Gitea/GiteaProviderClient.php index 18644ff..3581ecc 100644 --- a/app/Actions/Git/Gitea/GiteaProviderClient.php +++ b/app/Actions/Git/Gitea/GiteaProviderClient.php @@ -55,7 +55,7 @@ class GiteaProviderClient implements GitProviderClient ->throw() ->json(); - return $this->mapRepository($payload); + return $this->mapRepository($payload, $baseUrl); } /** @@ -85,7 +85,7 @@ class GiteaProviderClient implements GitProviderClient ->json(); foreach ($payload as $repository) { - $repositories[] = $this->mapRepository($repository); + $repositories[] = $this->mapRepository($repository, $baseUrl); } $page++; @@ -98,19 +98,17 @@ class GiteaProviderClient implements GitProviderClient * @return array */ public function listBranches( - string $baseUrl, string $token, - string $owner, - string $repository, + GitRepository $repository, ): array { $page = 1; $branches = []; do { /** @var array $payload */ - $payload = $this->request($baseUrl, $token) + $payload = $this->request($repository->baseUrl, $token) ->get( - '/repos/'.rawurlencode($owner).'/'.rawurlencode($repository).'/branches', + '/repos/'.rawurlencode($repository->owner).'/'.rawurlencode($repository->name).'/branches', [ 'limit' => 100, 'page' => $page, @@ -135,10 +133,8 @@ class GiteaProviderClient implements GitProviderClient * @return array */ public function listPullRequests( - string $baseUrl, string $token, - string $owner, - string $repository, + GitRepository $repository, ): array { $page = 1; $pullRequests = []; @@ -153,9 +149,9 @@ class GiteaProviderClient implements GitProviderClient * head: array{ref: string} * }> $payload */ - $payload = $this->request($baseUrl, $token) + $payload = $this->request($repository->baseUrl, $token) ->get( - '/repos/'.rawurlencode($owner).'/'.rawurlencode($repository).'/pulls', + '/repos/'.rawurlencode($repository->owner).'/'.rawurlencode($repository->name).'/pulls', [ 'limit' => 100, 'page' => $page, @@ -248,9 +244,10 @@ class GiteaProviderClient implements GitProviderClient * private: bool * } $repository */ - private function mapRepository(array $repository): GitRepository + private function mapRepository(array $repository, string $baseUrl): GitRepository { return new GitRepository( + baseUrl: $baseUrl, providerRepositoryId: (string) $repository['id'], owner: $repository['owner']['login'], name: $repository['name'], diff --git a/app/Actions/Git/ResolveTicketBranchMatches.php b/app/Actions/Git/ResolveTicketBranchMatches.php index dd8c8c4..41346f2 100644 --- a/app/Actions/Git/ResolveTicketBranchMatches.php +++ b/app/Actions/Git/ResolveTicketBranchMatches.php @@ -66,10 +66,8 @@ class ResolveTicketBranchMatches $this->gitProviderManager ->client($userGitConnection->provider) ->listBranches( - $userGitConnection->base_url, $userGitConnection->api_token, - $repositoryConnection->owner_name, - $repositoryConnection->repository_name, + $repositoryConnection->toGitRepository(), ), ) ->filter(fn (GitBranch $branch): bool => $this->ticketKeyReferenceMatcher->matches( @@ -85,7 +83,7 @@ class ResolveTicketBranchMatches $error = null; } catch (Throwable) { $branches = []; - $error = 'KanbanGit could not load branches from this repository right now.'; + $error = sprintf('%s could not load branches from this repository right now.', config('app.name')); } return [ diff --git a/app/Actions/Git/ResolveTicketPullRequestMatches.php b/app/Actions/Git/ResolveTicketPullRequestMatches.php index e009d8a..787c405 100644 --- a/app/Actions/Git/ResolveTicketPullRequestMatches.php +++ b/app/Actions/Git/ResolveTicketPullRequestMatches.php @@ -72,10 +72,8 @@ class ResolveTicketPullRequestMatches $this->gitProviderManager ->client($userGitConnection->provider) ->listPullRequests( - $userGitConnection->base_url, $userGitConnection->api_token, - $repositoryConnection->owner_name, - $repositoryConnection->repository_name, + $repositoryConnection->toGitRepository(), ), ) ->filter(fn (GitPullRequest $pullRequest): bool => $this->ticketKeyReferenceMatcher->matches( @@ -94,7 +92,7 @@ class ResolveTicketPullRequestMatches $error = null; } catch (Throwable) { - $error = 'KanbanGit could not load pull requests from this repository right now.'; + $error = sprintf('%s could not load pull requests from this repository right now.', config('app.name')); $pullRequests = []; } diff --git a/app/Actions/Projects/BuildProjectBacklogPageProps.php b/app/Actions/Projects/BuildProjectBacklogPageProps.php new file mode 100644 index 0000000..ef714e7 --- /dev/null +++ b/app/Actions/Projects/BuildProjectBacklogPageProps.php @@ -0,0 +1,49 @@ + + */ + public function handle( + Project $project, + bool $canEditTickets, + bool $canCreateTickets, + ?string $status, + ): array { + return [ + 'backlogTicketStatuses' => $project->ticketStatuses() + ->whereNull('board_column_id') + ->get(), + 'canCreateTickets' => $canCreateTickets, + 'canEditTickets' => $canEditTickets, + 'project' => $project, + 'status' => $status, + 'ticketStatusOptions' => $project->ticketStatuses() + ->with('boardColumn') + ->get(), + 'ticketPriorities' => $project->ticketPriorities()->get(), + 'ticketTypes' => $project->ticketTypes()->get(), + 'tickets' => 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(), + 'users' => User::query() + ->orderBy('name') + ->get(), + ]; + } +} diff --git a/app/Actions/Projects/BuildProjectBoardPageProps.php b/app/Actions/Projects/BuildProjectBoardPageProps.php new file mode 100644 index 0000000..8783aeb --- /dev/null +++ b/app/Actions/Projects/BuildProjectBoardPageProps.php @@ -0,0 +1,37 @@ + + */ + public function handle( + Project $project, + bool $canEditTickets, + bool $canCreateTickets, + ?string $status, + ): array { + return [ + 'boardColumns' => $project->boardColumns() + ->with([ + 'ticketStatuses', + 'tickets' => fn ($query) => $query + ->with([ + 'ticketPriority', + 'ticketStatus.allowedTargetStatuses.boardColumn', + 'ticketStatus.boardColumn', + 'ticketType', + ]), + ]) + ->get(), + 'canCreateTickets' => $canCreateTickets, + 'canEditTickets' => $canEditTickets, + 'project' => $project, + 'status' => $status, + ]; + } +} diff --git a/app/Actions/Tickets/BuildProjectTicketUpdateAttributes.php b/app/Actions/Tickets/BuildProjectTicketUpdateAttributes.php new file mode 100644 index 0000000..0a049f8 --- /dev/null +++ b/app/Actions/Tickets/BuildProjectTicketUpdateAttributes.php @@ -0,0 +1,48 @@ + $validated + * @return array{ + * acceptance_criteria: string|null, + * assignee_user_id: int|null, + * author_user_id: int|null, + * blocked_comment: string|null, + * description: string|null, + * is_blocked: bool, + * parent_ticket_id: int|null, + * story_points: int|null, + * ticket_priority_id: int, + * ticket_type_id: int, + * title: string + * } + */ + public function handle(Ticket $ticket, array $validated): array + { + return [ + 'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'), + 'assignee_user_id' => Arr::exists($validated, 'assignee_user_id') + ? Arr::get($validated, 'assignee_user_id') + : $ticket->assignee_user_id, + 'author_user_id' => Arr::exists($validated, 'author_user_id') + ? Arr::get($validated, 'author_user_id') + : $ticket->author_user_id, + 'blocked_comment' => Arr::get($validated, 'blocked_comment'), + 'description' => Arr::get($validated, 'description'), + 'is_blocked' => Arr::get($validated, 'is_blocked', false), + 'parent_ticket_id' => Arr::get($validated, 'parent_ticket_id'), + 'story_points' => Arr::exists($validated, 'story_points') + ? Arr::get($validated, 'story_points') + : $ticket->story_points, + 'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'), + 'ticket_type_id' => Arr::get($validated, 'ticket_type_id'), + 'title' => Arr::get($validated, 'title'), + ]; + } +} diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index c36946e..c7b01e6 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -2,6 +2,7 @@ namespace App\Actions\Tickets; +use App\Enums\TicketWorklogEntryType; use App\Models\Project; use App\Models\Ticket; use App\Models\TicketStatus; @@ -56,7 +57,7 @@ class CreateProjectTicket $this->createTicketWorklogEntry->handle( $ticket, $actor, - 'created', + TicketWorklogEntryType::Created, "Ticket was created in {$ticketStatus->name}.", [ 'from' => null, diff --git a/app/Actions/Tickets/CreateTicketWorklogEntry.php b/app/Actions/Tickets/CreateTicketWorklogEntry.php index 8690f51..61ba1c2 100644 --- a/app/Actions/Tickets/CreateTicketWorklogEntry.php +++ b/app/Actions/Tickets/CreateTicketWorklogEntry.php @@ -2,6 +2,7 @@ namespace App\Actions\Tickets; +use App\Enums\TicketWorklogEntryType; use App\Models\Ticket; use App\Models\TicketWorklogEntry; use App\Models\User; @@ -14,7 +15,7 @@ class CreateTicketWorklogEntry public function handle( Ticket $ticket, ?User $actor, - string $entryType, + TicketWorklogEntryType $entryType, string $summary, ?array $changes = null, ): TicketWorklogEntry { diff --git a/app/Actions/Tickets/SyncProjectTicketFieldValues.php b/app/Actions/Tickets/SyncProjectTicketFieldValues.php new file mode 100644 index 0000000..d7d2ce3 --- /dev/null +++ b/app/Actions/Tickets/SyncProjectTicketFieldValues.php @@ -0,0 +1,173 @@ + $fieldDefinitions + * @param Collection $existingFieldValues + * @param array $fieldValueAttributes + * @return array{ + * changes: array>, + * summary_labels: array + * } + */ + public function handle( + Ticket $ticket, + Collection $fieldDefinitions, + Collection $existingFieldValues, + array $fieldValueAttributes, + ): array { + $changes = []; + $summaryLabels = []; + + foreach ($fieldValueAttributes as $fieldValueAttribute) { + $fieldDefinition = $fieldDefinitions->get((int) $fieldValueAttribute['ticket_field_definition_id']); + + if ($fieldDefinition === null) { + continue; + } + + $existingFieldValue = $existingFieldValues->get($fieldDefinition->id); + + if ($fieldDefinition->field_type === 'user') { + $this->syncUserFieldValue( + $changes, + $existingFieldValue, + $fieldDefinition, + $fieldValueAttribute, + $summaryLabels, + $ticket, + ); + + continue; + } + + $this->syncNumberFieldValue( + $changes, + $existingFieldValue, + $fieldDefinition, + $fieldValueAttribute, + $summaryLabels, + $ticket, + ); + } + + return [ + 'changes' => $changes, + 'summary_labels' => $summaryLabels, + ]; + } + + /** + * @param array> $changes + * @param array{value_user_id?: int|string|null} $fieldValueAttribute + * @param array $summaryLabels + */ + private function syncUserFieldValue( + array &$changes, + ?TicketFieldValue $existingFieldValue, + TicketFieldDefinition $fieldDefinition, + array $fieldValueAttribute, + array &$summaryLabels, + Ticket $ticket, + ): void { + $newUserId = $fieldValueAttribute['value_user_id'] ?? null; + $newUserId = $newUserId === null || $newUserId === '' ? null : (int) $newUserId; + + if ($existingFieldValue?->value_user_id === $newUserId) { + return; + } + + $oldUserName = $existingFieldValue?->userValue?->name; + $newUserName = $newUserId === null ? null : User::query()->find($newUserId)?->name; + + $changes[$fieldDefinition->id] = [ + 'field' => $fieldDefinition->label, + 'from' => $oldUserName, + 'to' => $newUserName, + ]; + $summaryLabels[] = "{$fieldDefinition->label} field"; + + if ($newUserId === null) { + $existingFieldValue?->delete(); + + return; + } + + $ticket->fieldValues()->updateOrCreate( + [ + 'ticket_field_definition_id' => $fieldDefinition->id, + ], + [ + 'value_number' => null, + 'value_user_id' => $newUserId, + ], + ); + } + + /** + * @param array> $changes + * @param array{value_number?: float|int|string|null} $fieldValueAttribute + * @param array $summaryLabels + */ + private function syncNumberFieldValue( + array &$changes, + ?TicketFieldValue $existingFieldValue, + TicketFieldDefinition $fieldDefinition, + array $fieldValueAttribute, + array &$summaryLabels, + Ticket $ticket, + ): void { + $newNumber = $fieldValueAttribute['value_number'] ?? null; + $newNumber = $newNumber === null || $newNumber === '' ? null : (float) $newNumber; + $oldNumber = $existingFieldValue?->value_number; + + if ($oldNumber === $newNumber) { + return; + } + + $changes[$fieldDefinition->id] = [ + 'field' => $fieldDefinition->label, + 'from' => $this->formatNumber($oldNumber), + 'to' => $this->formatNumber($newNumber), + ]; + $summaryLabels[] = "{$fieldDefinition->label} field"; + + if ($newNumber === null) { + $existingFieldValue?->delete(); + + return; + } + + $ticket->fieldValues()->updateOrCreate( + [ + 'ticket_field_definition_id' => $fieldDefinition->id, + ], + [ + 'value_number' => $newNumber, + 'value_user_id' => null, + ], + ); + } + + private function formatNumber(?float $value): ?string + { + if ($value === null) { + return null; + } + + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } +} diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index 7f59412..c87061c 100644 --- a/app/Actions/Tickets/UpdateProjectTicket.php +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -2,6 +2,7 @@ namespace App\Actions\Tickets; +use App\Enums\TicketWorklogEntryType; use App\Models\Ticket; use App\Models\TicketFieldDefinition; use App\Models\TicketFieldValue; @@ -13,6 +14,7 @@ class UpdateProjectTicket { public function __construct( private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, + private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues, ) {} /** @@ -179,36 +181,19 @@ class UpdateProjectTicket $ticket->save(); } - foreach ($fieldValueAttributes as $fieldValueAttribute) { - $fieldDefinition = $fieldDefinitions->get((int) $fieldValueAttribute['ticket_field_definition_id']); + $fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle( + $ticket, + $fieldDefinitions, + $existingFieldValues, + $fieldValueAttributes, + ); - if ($fieldDefinition === null) { - continue; - } - - $existingFieldValue = $existingFieldValues->get($fieldDefinition->id); - - if ($fieldDefinition->field_type === 'user') { - $this->syncUserFieldValue( - $changes, - $existingFieldValue, - $fieldDefinition, - $fieldValueAttribute, - $summaryLabels, - $ticket, - ); - - continue; - } - - $this->syncNumberFieldValue( - $changes, - $existingFieldValue, - $fieldDefinition, - $fieldValueAttribute, - $summaryLabels, - $ticket, - ); + if ($fieldValueChangeSet['changes'] !== []) { + $changes['field_values'] = $fieldValueChangeSet['changes']; + $summaryLabels = [ + ...$summaryLabels, + ...$fieldValueChangeSet['summary_labels'], + ]; } if ($summaryLabels === []) { @@ -218,7 +203,7 @@ class UpdateProjectTicket $this->createTicketWorklogEntry->handle( $ticket->fresh(), $actor, - 'updated', + TicketWorklogEntryType::Updated, $this->buildSummary($summaryLabels), $changes, ); @@ -273,98 +258,6 @@ class UpdateProjectTicket $summaryLabels[] = $summaryLabel; } - /** - * @param array $changes - * @param array{value_user_id?: int|string|null} $fieldValueAttribute - * @param array $summaryLabels - */ - private function syncUserFieldValue( - array &$changes, - ?TicketFieldValue $existingFieldValue, - TicketFieldDefinition $fieldDefinition, - array $fieldValueAttribute, - array &$summaryLabels, - Ticket $ticket, - ): void { - $newUserId = $fieldValueAttribute['value_user_id'] ?? null; - $newUserId = $newUserId === null || $newUserId === '' ? null : (int) $newUserId; - - if ($existingFieldValue?->value_user_id === $newUserId) { - return; - } - - $oldUserName = $existingFieldValue?->userValue?->name; - $newUserName = $newUserId === null ? null : User::query()->find($newUserId)?->name; - - $changes['field_values'][$fieldDefinition->id] = [ - 'field' => $fieldDefinition->label, - 'from' => $oldUserName, - 'to' => $newUserName, - ]; - $summaryLabels[] = "{$fieldDefinition->label} field"; - - if ($newUserId === null) { - $existingFieldValue?->delete(); - - return; - } - - $ticket->fieldValues()->updateOrCreate( - [ - 'ticket_field_definition_id' => $fieldDefinition->id, - ], - [ - 'value_number' => null, - 'value_user_id' => $newUserId, - ], - ); - } - - /** - * @param array $changes - * @param array{value_number?: float|int|string|null} $fieldValueAttribute - * @param array $summaryLabels - */ - private function syncNumberFieldValue( - array &$changes, - ?TicketFieldValue $existingFieldValue, - TicketFieldDefinition $fieldDefinition, - array $fieldValueAttribute, - array &$summaryLabels, - Ticket $ticket, - ): void { - $newNumber = $fieldValueAttribute['value_number'] ?? null; - $newNumber = $newNumber === null || $newNumber === '' ? null : (float) $newNumber; - $oldNumber = $existingFieldValue?->value_number; - - if ($oldNumber === $newNumber) { - return; - } - - $changes['field_values'][$fieldDefinition->id] = [ - 'field' => $fieldDefinition->label, - 'from' => $this->formatNumber($oldNumber), - 'to' => $this->formatNumber($newNumber), - ]; - $summaryLabels[] = "{$fieldDefinition->label} field"; - - if ($newNumber === null) { - $existingFieldValue?->delete(); - - return; - } - - $ticket->fieldValues()->updateOrCreate( - [ - 'ticket_field_definition_id' => $fieldDefinition->id, - ], - [ - 'value_number' => $newNumber, - 'value_user_id' => null, - ], - ); - } - /** * @param array $summaryLabels */ @@ -386,13 +279,4 @@ class UpdateProjectTicket return 'Updated '.$uniqueLabels->implode(', ').', and '.$lastLabel.'.'; } - - private function formatNumber(?float $value): ?string - { - if ($value === null) { - return null; - } - - return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); - } } diff --git a/app/Enums/TicketWorklogEntryType.php b/app/Enums/TicketWorklogEntryType.php new file mode 100644 index 0000000..6a588a1 --- /dev/null +++ b/app/Enums/TicketWorklogEntryType.php @@ -0,0 +1,12 @@ +user(); - - abort_unless( - $this->permissionService->allows($user, Permission::ProjectRead, $project), - 403, - ); - - return Inertia::render('projects/Backlog', [ - 'backlogTicketStatuses' => $project->ticketStatuses() - ->whereNull('board_column_id') - ->get(), - 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), - 'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), - 'project' => $project, - 'status' => session('status'), - 'ticketStatusOptions' => $project->ticketStatuses() - ->with('boardColumn') - ->get(), - 'ticketPriorities' => $project->ticketPriorities()->get(), - 'ticketTypes' => $project->ticketTypes()->get(), - 'tickets' => 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(), - 'users' => User::query() - ->orderBy('name') - ->get(), - ]); - } - - public function store(StoreProjectTicketRequest $request, Project $project): RedirectResponse - { - $user = $request->user(); - $validated = $request->validated(); - - abort_unless( - $this->permissionService->allows($user, Permission::ProjectWrite, $project), - 403, - ); - - $ticket = $this->createProjectTicket->handle($project, $validated, $user); - - return to_route('projects.backlog.index', $project) - ->with('status', "{$ticket->ticket_key} was added to the backlog."); - } -} diff --git a/app/Http/Controllers/ProjectBoardController.php b/app/Http/Controllers/ProjectBoardController.php index 21a5c38..d9336a6 100644 --- a/app/Http/Controllers/ProjectBoardController.php +++ b/app/Http/Controllers/ProjectBoardController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; +use App\Actions\Projects\BuildProjectBoardPageProps; use App\Actions\Tickets\CreateTicketWorklogEntry; use App\Enums\Permission; +use App\Enums\TicketWorklogEntryType; use App\Http\Requests\MoveProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; @@ -18,6 +20,7 @@ use Inertia\Response; class ProjectBoardController extends Controller { public function __construct( + private readonly BuildProjectBoardPageProps $buildProjectBoardPageProps, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, private readonly PermissionService $permissionService, ) {} @@ -31,23 +34,12 @@ class ProjectBoardController extends Controller 403, ); - return Inertia::render('projects/Board', [ - 'boardColumns' => $project->boardColumns() - ->with([ - 'ticketStatuses', - 'tickets' => fn ($query) => $query - ->with([ - 'ticketPriority', - 'ticketStatus.allowedTargetStatuses.boardColumn', - 'ticketStatus.boardColumn', - 'ticketType', - ]), - ]) - ->get(), - 'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project), - 'project' => $project, - 'status' => session('status'), - ]); + return Inertia::render('projects/Board', $this->buildProjectBoardPageProps->handle( + $project, + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + session('status'), + )); } public function move( @@ -90,7 +82,7 @@ class ProjectBoardController extends Controller $this->createTicketWorklogEntry->handle( $ticket->fresh(), $user, - 'status_changed', + TicketWorklogEntryType::StatusChanged, "Moved from {$originLabel} to {$destinationLabel}.", [ 'from' => [ diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index a5be4ae..755a65a 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; +use App\Actions\Projects\BuildProjectBacklogPageProps; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Enums\Permission; use App\Http\Requests\DeleteProjectRequest; @@ -20,6 +21,7 @@ use Inertia\Response; class ProjectController extends Controller { public function __construct( + private readonly BuildProjectBacklogPageProps $buildProjectBacklogPageProps, private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata, private readonly PermissionService $permissionService, ) {} @@ -85,6 +87,23 @@ class ProjectController extends Controller ]); } + public function backlog(Request $request, Project $project): Response + { + $user = $request->user(); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectRead, $project), + 403, + ); + + return Inertia::render('projects/Backlog', $this->buildProjectBacklogPageProps->handle( + $project, + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + session('status'), + )); + } + public function general(Request $request, Project $project): Response { return Inertia::render('projects/settings/General', [ diff --git a/app/Http/Controllers/ProjectRepositorySettingsController.php b/app/Http/Controllers/ProjectRepositorySettingsController.php index 688e6d1..33c858c 100644 --- a/app/Http/Controllers/ProjectRepositorySettingsController.php +++ b/app/Http/Controllers/ProjectRepositorySettingsController.php @@ -189,7 +189,7 @@ class ProjectRepositorySettingsController extends Controller $error = null; } catch (Throwable) { $repositories = []; - $error = 'KanbanGit could not load repositories from this Git connection right now.'; + $error = sprintf('%s could not load repositories from this Git connection right now.', config('app.name')); } return [ diff --git a/app/Http/Controllers/ProjectTicketBranchController.php b/app/Http/Controllers/ProjectTicketBranchController.php index 672d11d..a3f663b 100644 --- a/app/Http/Controllers/ProjectTicketBranchController.php +++ b/app/Http/Controllers/ProjectTicketBranchController.php @@ -73,10 +73,8 @@ class ProjectTicketBranchController extends Controller $this->gitProviderManager ->client($userGitConnection->provider) ->listBranches( - $userGitConnection->base_url, $userGitConnection->api_token, - $repositoryConnection->owner_name, - $repositoryConnection->repository_name, + $repositoryConnection->toGitRepository(), ), ) ->contains( @@ -85,7 +83,7 @@ class ProjectTicketBranchController extends Controller } catch (Throwable) { return response()->json([ 'is_valid' => false, - 'message' => 'KanbanGit could not validate this branch name right now.', + 'message' => sprintf('%s could not validate this branch name right now.', config('app.name')), ]); } @@ -147,10 +145,8 @@ class ProjectTicketBranchController extends Controller try { $branchAlreadyExists = collect( $gitProvider->listBranches( - $userGitConnection->base_url, $userGitConnection->api_token, - $repositoryConnection->owner_name, - $repositoryConnection->repository_name, + $repositoryConnection->toGitRepository(), ), ) ->contains( @@ -158,7 +154,7 @@ class ProjectTicketBranchController extends Controller ); } catch (Throwable) { throw ValidationException::withMessages([ - 'branch_name' => 'KanbanGit could not validate this branch name right now.', + 'branch_name' => sprintf('%s could not validate this branch name right now.', config('app.name')), ]); } @@ -186,7 +182,7 @@ class ProjectTicketBranchController extends Controller ); } catch (Throwable) { throw ValidationException::withMessages([ - 'branch_name' => 'KanbanGit could not create this branch right now.', + 'branch_name' => sprintf('%s could not create this branch right now.', config('app.name')), ]); } diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 6fe36e7..288f3c0 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -6,8 +6,11 @@ use App\Actions\Git\BuildSuggestedTicketBranchName; use App\Actions\Git\ResolveTicketBranchMatches; use App\Actions\Git\ResolveTicketPullRequestMatches; use App\Actions\Permissions\PermissionService; +use App\Actions\Tickets\BuildProjectTicketUpdateAttributes; +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\Models\Project; use App\Models\Ticket; @@ -22,13 +25,68 @@ use Inertia\Response; class ProjectTicketController extends Controller { public function __construct( + private readonly BuildProjectTicketUpdateAttributes $buildProjectTicketUpdateAttributes, private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName, + private readonly CreateProjectTicket $createProjectTicket, private readonly PermissionService $permissionService, private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches, private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches, private readonly UpdateProjectTicket $updateProjectTicket, ) {} + public function create(Request $request, Project $project): Response + { + $user = $request->user(); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 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' => $project, + 'source' => $source, + 'status' => session('status'), + 'ticketPriorities' => $project->ticketPriorities()->get(), + 'ticketStatusOptions' => $project->ticketStatuses() + ->with('boardColumn') + ->get(), + 'ticketTypes' => $project->ticketTypes()->get(), + 'users' => User::query() + ->orderBy('name') + ->get(), + ]); + } + + public function store(StoreProjectTicketRequest $request, Project $project): RedirectResponse + { + $user = $request->user(); + + abort_unless( + $this->permissionService->allows($user, Permission::ProjectWrite, $project), + 403, + ); + + $ticket = $this->createProjectTicket->handle( + $project, + $request->validated(), + $user, + ); + + return to_route('projects.tickets.show', [$project, $ticket]) + ->with('status', "{$ticket->ticket_key} was created."); + } + public function show(Request $request, Project $project, Ticket $ticket): Response { $user = $request->user(); @@ -40,7 +98,6 @@ class ProjectTicketController extends Controller $canEditTicket = $this->permissionService->allows($user, Permission::ProjectWrite, $project); $canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project); - $excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)]; $displayTicket = $ticket->load([ 'assignee', 'author', @@ -65,7 +122,7 @@ class ProjectTicketController extends Controller 'linkedTickets' => $this->linkedTickets($ticket), 'parentTicketOptions' => Ticket::query() ->where('project_id', $project->id) - ->whereNotIn('id', $excludedParentIds) + ->whereKeyNot($ticket->id) ->whereHas('ticketType', fn ($query) => $query->where('is_parent', true)) ->orderByDesc('sequence_number') ->get() @@ -122,25 +179,7 @@ class ProjectTicketController extends Controller $validated = $request->validated(); $fieldValues = Arr::get($validated, 'field_values', []); - $ticketAttributes = [ - 'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'), - 'assignee_user_id' => Arr::exists($validated, 'assignee_user_id') - ? Arr::get($validated, 'assignee_user_id') - : $ticket->assignee_user_id, - 'author_user_id' => Arr::exists($validated, 'author_user_id') - ? Arr::get($validated, 'author_user_id') - : $ticket->author_user_id, - 'blocked_comment' => Arr::get($validated, 'blocked_comment'), - 'description' => Arr::get($validated, 'description'), - 'is_blocked' => Arr::get($validated, 'is_blocked', false), - 'parent_ticket_id' => Arr::get($validated, 'parent_ticket_id'), - 'story_points' => Arr::exists($validated, 'story_points') - ? Arr::get($validated, 'story_points') - : $ticket->story_points, - 'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'), - 'ticket_type_id' => Arr::get($validated, 'ticket_type_id'), - 'title' => Arr::get($validated, 'title'), - ]; + $ticketAttributes = $this->buildProjectTicketUpdateAttributes->handle($ticket, $validated); $wasUpdated = $this->updateProjectTicket->handle( $ticket, @@ -181,20 +220,6 @@ class ProjectTicketController extends Controller ->all(); } - /** - * @return array - */ - private function descendantIds(Ticket $ticket): array - { - return $ticket->childTickets() - ->get() - ->flatMap(function (Ticket $childTicket): array { - return [$childTicket->id, ...$this->descendantIds($childTicket)]; - }) - ->values() - ->all(); - } - /** * @return arraycreateTicketWorklogEntry->handle( $ticket, $user, - 'link_updated', + TicketWorklogEntryType::LinkUpdated, "Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.", [ 'linked_ticket_key' => $linkedTicket->ticket_key, @@ -70,7 +71,7 @@ class ProjectTicketLinkController extends Controller $this->createTicketWorklogEntry->handle( $linkedTicket, $user, - 'link_updated', + TicketWorklogEntryType::LinkUpdated, "Linked {$ticket->ticket_key} as {$ticketLink->ticketLinkType->label}.", [ 'linked_ticket_key' => $ticket->ticket_key, @@ -117,7 +118,7 @@ class ProjectTicketLinkController extends Controller $this->createTicketWorklogEntry->handle( $ticket, $user, - 'link_removed', + TicketWorklogEntryType::LinkRemoved, "Removed {$label} link to {$linkedTicket->ticket_key}.", [ 'linked_ticket_key' => $linkedTicket->ticket_key, @@ -128,7 +129,7 @@ class ProjectTicketLinkController extends Controller $this->createTicketWorklogEntry->handle( $linkedTicket, $user, - 'link_removed', + TicketWorklogEntryType::LinkRemoved, "Removed {$label} link to {$ticket->ticket_key}.", [ 'linked_ticket_key' => $ticket->ticket_key, diff --git a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php index b563820..407a522 100644 --- a/app/Http/Controllers/ProjectTicketMetadataSettingsController.php +++ b/app/Http/Controllers/ProjectTicketMetadataSettingsController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Actions\Permissions\PermissionService; use App\Actions\Tickets\CreateTicketWorklogEntry; use App\Enums\Permission; +use App\Enums\TicketWorklogEntryType; use App\Http\Requests\DestroyProjectTicketTypeRequest; use App\Http\Requests\StoreProjectNamedSettingRequest; use App\Http\Requests\StoreProjectTicketTypeRequest; @@ -158,7 +159,7 @@ class ProjectTicketMetadataSettingsController extends Controller $this->createTicketWorklogEntry->handle( $ticket->fresh(), $request->user(), - 'updated', + TicketWorklogEntryType::Updated, "Updated type because {$deletedName} type was deleted.", [ 'ticket_type' => [ diff --git a/app/Http/Controllers/Settings/GitConnectionController.php b/app/Http/Controllers/Settings/GitConnectionController.php index 8fbea25..a57c7df 100644 --- a/app/Http/Controllers/Settings/GitConnectionController.php +++ b/app/Http/Controllers/Settings/GitConnectionController.php @@ -61,7 +61,7 @@ class GitConnectionController extends Controller } catch (Throwable) { return back() ->withErrors([ - 'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.', + 'api_token' => sprintf('%s could not authenticate against Gitea with this base URL and token.', config('app.name')), ]); } @@ -99,7 +99,7 @@ class GitConnectionController extends Controller } catch (Throwable) { return back() ->withErrors([ - 'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.', + 'api_token' => sprintf('%s could not authenticate against Gitea with this base URL and token.', config('app.name')), ]); } diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index 24bac0a..be0545f 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -39,10 +39,7 @@ class StoreProjectTicketRequest extends FormRequest 'ticket_status_id' => [ 'required', 'integer', - Rule::exists('ticket_statuses', 'id') - ->where(fn ($query) => $query - ->where('project_id', $project->id) - ->whereNull('board_column_id')), + Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), ], 'ticket_type_id' => [ 'required', diff --git a/app/Models/RepositoryConnection.php b/app/Models/RepositoryConnection.php index 752ea70..44414e5 100644 --- a/app/Models/RepositoryConnection.php +++ b/app/Models/RepositoryConnection.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\Git\Data\GitRepository; use App\Actions\Git\GitProviderKey; use Database\Factories\RepositoryConnectionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -38,4 +39,18 @@ class RepositoryConnection extends Model { return $this->belongsTo(Project::class); } + + public function toGitRepository(): GitRepository + { + return new GitRepository( + baseUrl: $this->base_url, + providerRepositoryId: $this->provider_repository_id, + owner: $this->owner_name, + name: $this->repository_name, + fullName: $this->repository_full_name, + htmlUrl: $this->repository_url, + defaultBranch: null, + isPrivate: false, + ); + } } diff --git a/app/Models/TicketWorklogEntry.php b/app/Models/TicketWorklogEntry.php index bc4a03d..d06ef0c 100644 --- a/app/Models/TicketWorklogEntry.php +++ b/app/Models/TicketWorklogEntry.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\TicketWorklogEntryType; use Database\Factories\TicketWorklogEntryFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -27,6 +28,7 @@ class TicketWorklogEntry extends Model { return [ 'changes' => 'array', + 'entry_type' => TicketWorklogEntryType::class, ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 51e55d9..78f4da5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -57,6 +57,9 @@ class AppServiceProvider extends ServiceProvider Gate::define('access-admin', fn (User $user): bool => $permissionService->allows($user, Permission::GlobalAdmin)); Gate::define('create-project', fn (User $user): bool => $permissionService->allows($user, Permission::ProjectCreate)); Gate::define('create-team', fn (User $user): bool => $permissionService->allows($user, Permission::TeamCreate)); + Gate::define('admin-project', fn (User $user, Project $project): bool => $permissionService->allows($user, Permission::ProjectAdmin, $project)); + Gate::define('view-project', fn (User $user, Project $project): bool => $permissionService->allows($user, Permission::ProjectRead, $project)); + Gate::define('write-project', fn (User $user, Project $project): bool => $permissionService->allows($user, Permission::ProjectWrite, $project)); Gate::define('manage-teams', fn (User $user): bool => $permissionService->canManageAnyTeam($user)); Gate::define('view-teams', fn (User $user): bool => Gate::forUser($user)->any(['create-team', 'manage-teams'])); } diff --git a/database/factories/TicketWorklogEntryFactory.php b/database/factories/TicketWorklogEntryFactory.php index 093c732..e0f8613 100644 --- a/database/factories/TicketWorklogEntryFactory.php +++ b/database/factories/TicketWorklogEntryFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\TicketWorklogEntryType; use App\Models\Ticket; use App\Models\TicketWorklogEntry; use App\Models\User; @@ -25,7 +26,7 @@ class TicketWorklogEntryFactory extends Factory 'from' => 'Backlog', 'to' => 'In Progress', ], - 'entry_type' => 'status_changed', + 'entry_type' => TicketWorklogEntryType::StatusChanged, 'summary' => 'Moved from Backlog to In Progress.', 'ticket_id' => Ticket::factory(), ]; diff --git a/docs/missing-features.md b/docs/missing-features.md index 34d2115..a17ac00 100644 --- a/docs/missing-features.md +++ b/docs/missing-features.md @@ -1,23 +1,12 @@ ## Missing features -### Ticket - -#### general -- (mehr über middlewares steuern) -- why uses everywhere forceFill? - - app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket... - - mehrere actions/quereys in services auslagern - -#### later -- repository_connections check base Url maybe unnessecary? -- Git listBranches replace baseUrl, owner, rperoName with GItReprository -- Git prs replace baseUrl, owner, rperoName with GItReprository -- replace Kanbangit with env App Name -- 06 - - change Project Backlog Controller → Project Controller - - remove store from project board controller and route projects/{project}/backlog, change to ticket controller, create -- 08 - - worklog überarbeiten → type als worklog enum → json je nach enum type - - UpdateProjectTicket aufräumen -- 09 - - app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null +- resources benutzen + - Für jedes Model / Route, resources benutzen, kein mapping/feste arrays mehr in den controller. + - kein 'model' => $model (bspw. 'project' => $project) mehr. + - niemals das model in reponse return. für jedes model deshalb eine resource und diese benutzen. + - in der resource niemals mit only oder manuel die props übergeben. + - Ziel: neue attribute von model sollen nicht ausversehen in responses auftauchen & responses kleiner, falls manche attr nicht gebraucht werden + - folgende actions löschen und durch resource benutzen: + - app/Actions/Projects/BuildProjectBacklogPageProps.php + - app/Actions/Projects/BuildProjectBoardPageProps.php + - app/Actions/Tickets/BuildProjectTicketUpdateAttributes.php diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index 40cc5b9..788a072 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -1,9 +1,7 @@ + +