fixed missing features

This commit is contained in:
2026-04-15 09:56:53 +02:00
parent de461ad73a
commit cec0e397b6
44 changed files with 1102 additions and 561 deletions
@@ -30,20 +30,16 @@ interface GitProviderClient
* @return array<int, GitBranch>
*/
public function listBranches(
string $baseUrl,
string $token,
string $owner,
string $repository,
GitRepository $repository,
): array;
/**
* @return array<int, GitPullRequest>
*/
public function listPullRequests(
string $baseUrl,
string $token,
string $owner,
string $repository,
GitRepository $repository,
): array;
public function createBranch(
+1
View File
@@ -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,
+10 -13
View File
@@ -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<int, GitBranch>
*/
public function listBranches(
string $baseUrl,
string $token,
string $owner,
string $repository,
GitRepository $repository,
): array {
$page = 1;
$branches = [];
do {
/** @var array<int, array{name: string}> $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<int, GitPullRequest>
*/
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'],
@@ -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 = ((string) config('app.name')).' could not load branches from this repository right now.';
}
return [
@@ -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 = ((string) config('app.name')).' could not load pull requests from this repository right now.';
$pullRequests = [];
}
@@ -0,0 +1,49 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
class BuildProjectBacklogPageProps
{
/**
* @return array<string, mixed>
*/
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(),
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
class BuildProjectBoardPageProps
{
/**
* @return array<string, mixed>
*/
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,
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Actions\Tickets;
use App\Models\Ticket;
use Illuminate\Support\Arr;
class BuildProjectTicketUpdateAttributes
{
/**
* @param array<string, mixed> $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'),
];
}
}
+2 -1
View File
@@ -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,
@@ -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 {
@@ -0,0 +1,173 @@
<?php
namespace App\Actions\Tickets;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
use App\Models\User;
use Illuminate\Support\Collection;
class SyncProjectTicketFieldValues
{
/**
* @param Collection<int, TicketFieldDefinition> $fieldDefinitions
* @param Collection<int, TicketFieldValue> $existingFieldValues
* @param array<int, array{
* ticket_field_definition_id: int,
* value_number?: float|int|string|null,
* value_user_id?: int|string|null
* }> $fieldValueAttributes
* @return array{
* changes: array<int, array<string, mixed>>,
* summary_labels: array<int, string>
* }
*/
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<int, array<string, mixed>> $changes
* @param array{value_user_id?: int|string|null} $fieldValueAttribute
* @param array<int, string> $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<int, array<string, mixed>> $changes
* @param array{value_number?: float|int|string|null} $fieldValueAttribute
* @param array<int, string> $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'), '.');
}
}
+15 -131
View File
@@ -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<string, mixed> $changes
* @param array{value_user_id?: int|string|null} $fieldValueAttribute
* @param array<int, string> $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<string, mixed> $changes
* @param array{value_number?: float|int|string|null} $fieldValueAttribute
* @param array<int, string> $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<int, string> $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'), '.');
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum TicketWorklogEntryType: string
{
case Created = 'created';
case LinkRemoved = 'link_removed';
case LinkUpdated = 'link_updated';
case StatusChanged = 'status_changed';
case Updated = 'updated';
}
@@ -1,78 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateProjectTicket;
use App\Enums\Permission;
use App\Http\Requests\StoreProjectTicketRequest;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectBacklogController extends Controller
{
public function __construct(
private readonly CreateProjectTicket $createProjectTicket,
private readonly PermissionService $permissionService,
) {}
public function index(Request $request, Project $project): Response
{
$user = $request->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.");
}
}
+10 -18
View File
@@ -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' => [
@@ -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', [
@@ -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 = ((string) config('app.name')).' could not load repositories from this Git connection right now.';
}
return [
@@ -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' => ((string) config('app.name')).' could not validate this branch name right now.',
]);
}
@@ -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' => ((string) config('app.name')).' could not validate this branch name right now.',
]);
}
@@ -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' => ((string) config('app.name')).' could not create this branch right now.',
]);
}
@@ -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<int, int>
*/
private function descendantIds(Ticket $ticket): array
{
return $ticket->childTickets()
->get()
->flatMap(function (Ticket $childTicket): array {
return [$childTicket->id, ...$this->descendantIds($childTicket)];
})
->values()
->all();
}
/**
* @return array<int, array{
* created_at: string|null,
@@ -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\StoreTicketLinkRequest;
use App\Models\Project;
use App\Models\Ticket;
@@ -59,7 +60,7 @@ class ProjectTicketLinkController extends Controller
$this->createTicketWorklogEntry->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,
@@ -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' => [
@@ -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' => ((string) config('app.name')).' could not authenticate against Gitea with this base URL and token.',
]);
}
@@ -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' => ((string) config('app.name')).' could not authenticate against Gitea with this base URL and token.',
]);
}
@@ -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',
+15
View File
@@ -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,
);
}
}
+2
View File
@@ -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,
];
}
+3
View File
@@ -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']));
}