Merge pull request 'missing-features' (#31) from missing-features into main
tests / ci (8.4) (push) Has been cancelled
linter / quality (push) Has been cancelled

Reviewed-on: OpenSource/KanbanGit#31
This commit was merged in pull request #31.
This commit is contained in:
2026-04-15 16:33:38 +00:00
44 changed files with 1092 additions and 582 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 = sprintf('%s could not load branches from this repository right now.', config('app.name'));
}
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 = sprintf('%s could not load pull requests from this repository right now.', config('app.name'));
$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 = sprintf('%s could not load repositories from this Git connection right now.', config('app.name'));
}
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' => 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')),
]);
}
@@ -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' => 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')),
]);
}
@@ -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']));
}
@@ -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(),
];
+10 -21
View File
@@ -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
+14 -202
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBacklogController from '@/actions/App/Http/Controllers/ProjectBacklogController';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -13,14 +11,11 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index';
import { show as showTicket } from '@/routes/projects/tickets';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -42,11 +37,6 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const hasTicketMetadata =
props.ticketTypes.length > 0 &&
props.ticketPriorities.length > 0 &&
props.backlogTicketStatuses.length > 0;
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
@@ -92,6 +82,19 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div>
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'backlog' } },
)
"
>
Create ticket
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
@@ -109,197 +112,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
{{ status }}
</div>
<Card v-if="canCreateTickets && hasTicketMetadata">
<CardHeader>
<CardTitle>Create ticket</CardTitle>
<CardDescription>
New tickets receive the next immutable project key and
enter the selected backlog status.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="ProjectBacklogController.store.form(project.id)"
class="grid gap-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
name="title"
required
autofocus
placeholder="Implement repository sync"
/>
<InputError :message="errors.title" />
</div>
<div class="flex gap-2">
<div class="flex-4/5 grid gap-2 md:gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
name="description"
rows="4"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError
:message="errors.acceptance_criteria"
/>
</div>
</div>
<div
class="grid h-fit flex-1 gap-2 md:ml-4 md:gap-6"
>
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
name="ticket_type_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
name="ticket_priority_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">
Status
</Label>
<select
id="ticket_status_id"
name="ticket_status_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketStatus in backlogTicketStatuses"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{ ticketStatus.name }}
</option>
</select>
<InputError
:message="errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
name="assignee_user_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="errors.assignee_user_id"
/>
</div>
<div
class="grid gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
type="number"
step="1"
/>
<InputError
:message="errors.story_points"
/>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="processing">
Create ticket
</Button>
</div>
</Form>
</CardContent>
</Card>
<Card v-else-if="canCreateTickets">
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
Ticket creation needs at least one type and one
priority, plus one backlog status.
</p>
<div>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Open ticket settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Backlog tickets</CardTitle>
+14 -1
View File
@@ -16,7 +16,7 @@ import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import { show as showTicket } from '@/routes/projects/tickets';
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -83,6 +83,19 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div>
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
createTicket(
{ project: project.id },
{ query: { source: 'board' } },
)
"
>
Create ticket
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
>Open backlog</Link
+4 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Form, useForm, Head, Link } from '@inertiajs/vue3';
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
@@ -38,6 +38,8 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>();
const page = usePage();
const appName = String(page.props.name);
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
@@ -270,7 +272,7 @@ watch(
branchNameValidation.value = {
is_valid: false,
message:
'KanbanGit could not validate this branch name right now.',
`${appName} could not validate this branch name right now.`,
};
} finally {
if (!abortController.signal.aborted) {
@@ -0,0 +1,308 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>();
const returnHref =
props.source === 'board'
? boardIndex(props.project.id)
: backlogIndex(props.project.id);
const returnLabel = props.source === 'board' ? 'Back to board' : 'Back to backlog';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: props.source === 'board' ? 'Board' : 'Backlog',
href: returnHref,
},
{
title: 'Create ticket',
href: ProjectTicketController.create({
project: props.project.id,
}),
},
];
const hasTicketMetadata =
props.ticketTypes.length > 0 &&
props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0;
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Create ${project.name} ticket`" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
title="Create ticket"
:description="`Add a ticket to ${project.name}.`"
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="returnHref">{{ returnLabel }}</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<Card v-if="hasTicketMetadata">
<CardHeader>
<CardTitle>Ticket details</CardTitle>
<CardDescription>
The ticket key is assigned after creation.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="ProjectTicketController.store.form(project.id)"
class="grid gap-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
name="title"
required
autofocus
placeholder="Implement repository sync"
/>
<InputError :message="errors.title" />
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
name="description"
rows="4"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError
:message="errors.acceptance_criteria"
/>
</div>
</div>
<div class="grid h-fit gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
name="ticket_type_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
name="ticket_priority_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">Status</Label>
<select
id="ticket_status_id"
name="ticket_status_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="ticketStatus.id"
:selected="
ticketStatus.id ===
defaultTicketStatusId
"
>
{{ ticketStatusLabel(ticketStatus) }}
</option>
</select>
<InputError
:message="errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
name="assignee_user_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="errors.assignee_user_id"
/>
</div>
<div
v-if="project.show_story_points"
class="grid gap-2"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
type="number"
step="1"
/>
<InputError
:message="errors.story_points"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" :disabled="processing">
Create ticket
</Button>
<Button as-child variant="outline">
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
<Card v-else>
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
Ticket creation needs at least one type, one priority,
and one status.
</p>
<div>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Open ticket settings
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
import { computed, watch } from 'vue';
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
import Heading from '@/components/Heading.vue';
@@ -31,6 +31,8 @@ import type {
} from '@/types';
const props = defineProps<ProjectRepositorySettingsPageProps>();
const page = usePage();
const appName = String(page.props.name);
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -285,7 +287,7 @@ const submitUpdate = (): void => {
</Link>
</Button>
<p class="text-muted-foreground text-sm">
Without a matching personal connection, KanbanGit only
Without a matching personal connection, {{ appName }} only
shows the repository name and links you to your user
settings.
</p>
@@ -612,7 +614,7 @@ const submitUpdate = (): void => {
>
This project link is visible, but you need a
matching Git connection in your user
settings before KanbanGit can open or query
settings before {{ appName }} can open or query
this repository for you.
</p>
@@ -648,7 +650,7 @@ const submitUpdate = (): void => {
<CardHeader class="border-b">
<CardTitle>Detected personal sources</CardTitle>
<CardDescription>
KanbanGit loads repositories from your personal
{{ appName }} loads repositories from your personal
Git connections and offers them for project
linking.
</CardDescription>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Form, Head, usePage } from '@inertiajs/vue3';
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
@@ -21,6 +21,9 @@ import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
defineProps<SettingsGitConnectionsPageProps>();
const page = usePage();
const appName = String(page.props.name);
const breadcrumbItems: BreadcrumbItem[] = [
{
title: 'Git connections',
@@ -43,7 +46,7 @@ const breadcrumbItems: BreadcrumbItem[] = [
/>
<p class="text-muted-foreground text-sm">
Each user needs their own application key. Without a
matching personal connection, KanbanGit can only show
matching personal connection, {{ appName }} can only show
repository names and will link you back to this page.
</p>
</div>
@@ -1,12 +1,19 @@
import type { UserModel } from '@/types/models/user-model';
export type TicketWorklogEntryType =
| 'created'
| 'link_removed'
| 'link_updated'
| 'status_changed'
| 'updated';
export type TicketWorklogEntryModel = {
id: number;
actor: UserModel | null;
actor_id: number | null;
changes: Record<string, unknown> | null;
created_at: string;
entry_type: string;
entry_type: TicketWorklogEntryType;
summary: string;
ticket_id: number;
updated_at: string;
+12
View File
@@ -73,6 +73,7 @@ export type ProjectGeneralSettingsPageProps = {
export type ProjectBoardPageProps = {
boardColumns: BoardColumnModel[];
canCreateTickets: boolean;
canEditTickets: boolean;
project: ProjectModel;
status?: string | null;
@@ -114,6 +115,17 @@ export type ProjectBacklogPageProps = {
users: UserModel[];
};
export type ProjectTicketCreatePageProps = {
defaultTicketStatusId: number | null;
project: ProjectModel;
source: 'backlog' | 'board';
status?: string | null;
ticketPriorities: TicketPriorityModel[];
ticketStatusOptions: TicketStatusModel[];
ticketTypes: TicketTypeModel[];
users: UserModel[];
};
export type ProjectTicketPageProps = {
canEditTicket: boolean;
canManageProject: boolean;
+58 -8
View File
@@ -1,7 +1,6 @@
<?php
use App\Http\Controllers\Admin\UserManagementController;
use App\Http\Controllers\ProjectBacklogController;
use App\Http\Controllers\ProjectBoardController;
use App\Http\Controllers\ProjectBoardSettingsController;
use App\Http\Controllers\ProjectController;
@@ -21,96 +20,147 @@ Route::inertia('/', 'Welcome', [
Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
Route::resource('projects', ProjectController::class)
->only(['index', 'create', 'store', 'show']);
Route::get('projects', [ProjectController::class, 'index'])
->name('projects.index');
Route::get('projects/create', [ProjectController::class, 'create'])
->middleware('can:create-project')
->name('projects.create');
Route::post('projects', [ProjectController::class, 'store'])
->middleware('can:create-project')
->name('projects.store');
Route::get('projects/{project}', [ProjectController::class, 'show'])
->middleware('can:view-project,project')
->name('projects.show');
Route::delete('projects/{project}', [ProjectController::class, 'destroy'])
->middleware('can:access-admin')
->name('projects.destroy');
Route::get('projects/{project}/access', [ProjectController::class, 'access'])
->middleware('can:admin-project,project')
->name('projects.access');
Route::patch('projects/{project}/permissions/users/{user}', [ProjectController::class, 'updateUserPermissions'])
->middleware('can:admin-project,project')
->name('projects.permissions.users.update');
Route::patch('projects/{project}/permissions/teams/{team}', [ProjectController::class, 'updateTeamPermissions'])
->middleware('can:admin-project,project')
->name('projects.permissions.teams.update');
Route::get('projects/{project}/backlog', [ProjectBacklogController::class, 'index'])
->name('projects.backlog.index');
Route::post('projects/{project}/backlog', [ProjectBacklogController::class, 'store'])
->name('projects.backlog.store');
Route::scopeBindings()->group(function (): void {
Route::get('projects/{project}/board', [ProjectBoardController::class, 'index'])
->middleware('can:view-project,project')
->name('projects.board.index');
Route::get('projects/{project}/backlog', [ProjectController::class, 'backlog'])
->middleware('can:view-project,project')
->name('projects.backlog.index');
Route::get('projects/{project}/tickets/create', [ProjectTicketController::class, 'create'])
->middleware('can:write-project,project')
->name('projects.tickets.create');
Route::post('projects/{project}/tickets', [ProjectTicketController::class, 'store'])
->middleware('can:write-project,project')
->name('projects.tickets.store');
Route::get('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'show'])
->middleware('can:view-project,project')
->name('projects.tickets.show');
Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'update'])
->middleware('can:write-project,project')
->name('projects.tickets.update');
Route::get('projects/{project}/tickets/{ticket}/branches/validate', [ProjectTicketBranchController::class, 'validate'])
->middleware('can:write-project,project')
->name('projects.tickets.branches.validate');
Route::post('projects/{project}/tickets/{ticket}/branches', [ProjectTicketBranchController::class, 'store'])
->middleware('can:write-project,project')
->name('projects.tickets.branches.store');
Route::post('projects/{project}/tickets/{ticket}/links', [ProjectTicketLinkController::class, 'store'])
->middleware('can:write-project,project')
->name('projects.tickets.links.store');
Route::delete('projects/{project}/tickets/{ticket}/links/{ticketLink}', [ProjectTicketLinkController::class, 'destroy'])
->middleware('can:write-project,project')
->withoutScopedBindings()
->name('projects.tickets.links.destroy');
Route::patch('projects/{project}/tickets/{ticket}/status', [ProjectBoardController::class, 'move'])
->middleware('can:write-project,project')
->name('projects.tickets.status.update');
Route::get('projects/{project}/settings/general', [ProjectController::class, 'general'])
->middleware('can:access-admin')
->middleware('can:admin-project,project')
->name('projects.settings.general');
Route::get('projects/{project}/settings/board', [ProjectBoardSettingsController::class, 'show'])
->middleware('can:admin-project,project')
->name('projects.settings.board');
Route::post('projects/{project}/settings/board/columns', [ProjectBoardSettingsController::class, 'store'])
->middleware('can:admin-project,project')
->name('projects.settings.board.columns.store');
Route::patch('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'update'])
->middleware('can:admin-project,project')
->name('projects.settings.board.columns.update');
Route::delete('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'destroy'])
->middleware('can:admin-project,project')
->name('projects.settings.board.columns.destroy');
Route::post('projects/{project}/settings/board/statuses', [ProjectBoardSettingsController::class, 'storeStatus'])
->middleware('can:admin-project,project')
->name('projects.settings.board.statuses.store');
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'updateStatus'])
->middleware('can:admin-project,project')
->name('projects.settings.board.statuses.update');
Route::delete('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'destroyStatus'])
->middleware('can:admin-project,project')
->name('projects.settings.board.statuses.destroy');
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}/transitions', [ProjectBoardSettingsController::class, 'updateStatusTransitions'])
->middleware('can:admin-project,project')
->name('projects.settings.board.statuses.transitions.update');
Route::get('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'show'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata');
Route::get('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'show'])
->middleware('can:admin-project,project')
->name('projects.settings.repositories');
Route::post('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'store'])
->middleware('can:admin-project,project')
->name('projects.settings.repositories.store');
Route::patch('projects/{project}/settings/repositories/{repositoryConnection}', [ProjectRepositorySettingsController::class, 'update'])
->middleware('can:admin-project,project')
->name('projects.settings.repositories.update');
Route::delete('projects/{project}/settings/repositories/{repositoryConnection}', [ProjectRepositorySettingsController::class, 'destroy'])
->middleware('can:admin-project,project')
->name('projects.settings.repositories.destroy');
Route::post('projects/{project}/settings/metadata/types', [ProjectTicketMetadataSettingsController::class, 'storeType'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.types.store');
Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.types.update');
Route::patch('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'update'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.update');
Route::delete('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'destroyType'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.types.destroy');
Route::post('projects/{project}/settings/metadata/priorities', [ProjectTicketMetadataSettingsController::class, 'storePriority'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.priorities.store');
Route::patch('projects/{project}/settings/metadata/priorities/{ticketPriority}', [ProjectTicketMetadataSettingsController::class, 'updatePriority'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.priorities.update');
Route::delete('projects/{project}/settings/metadata/priorities/{ticketPriority}', [ProjectTicketMetadataSettingsController::class, 'destroyPriority'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.priorities.destroy');
Route::post('projects/{project}/settings/metadata/fields', [ProjectTicketMetadataSettingsController::class, 'storeFieldDefinition'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.fields.store');
Route::patch('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'updateFieldDefinition'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.fields.update');
Route::delete('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'destroyFieldDefinition'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.fields.destroy');
Route::post('projects/{project}/settings/metadata/link-types', [ProjectTicketMetadataSettingsController::class, 'storeLinkType'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.link-types.store');
Route::patch('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'updateLinkType'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.link-types.update');
Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType'])
->middleware('can:admin-project,project')
->name('projects.settings.metadata.link-types.destroy');
});
+22 -6
View File
@@ -74,6 +74,7 @@ class GiteaProviderTest extends TestCase
$this->assertCount(1, $repositories);
$this->assertContainsOnlyInstancesOf(GitRepository::class, $repositories);
$this->assertSame('https://git.example.test/api/v1', $repositories[0]->baseUrl);
$this->assertSame('501', $repositories[0]->providerRepositoryId);
$this->assertSame('nico/kanbangit', $repositories[0]->fullName);
$this->assertSame('main', $repositories[0]->defaultBranch);
@@ -113,6 +114,7 @@ class GiteaProviderTest extends TestCase
);
$this->assertInstanceOf(GitRepository::class, $repository);
$this->assertSame('https://git.example.test', $repository->baseUrl);
$this->assertSame('501', $repository->providerRepositoryId);
$this->assertSame('core-team', $repository->owner);
$this->assertSame('kanbangit', $repository->name);
@@ -138,10 +140,17 @@ class GiteaProviderTest extends TestCase
]);
$branches = app(GiteaProviderClient::class)->listBranches(
'https://git.example.test',
'branch-token',
'core-team',
'kanbangit',
new GitRepository(
baseUrl: 'https://git.example.test',
providerRepositoryId: '501',
owner: 'core-team',
name: 'kanbangit',
fullName: 'core-team/kanbangit',
htmlUrl: 'https://git.example.test/core-team/kanbangit',
defaultBranch: 'main',
isPrivate: false,
),
);
$this->assertCount(2, $branches);
@@ -210,10 +219,17 @@ class GiteaProviderTest extends TestCase
]);
$pullRequests = app(GiteaProviderClient::class)->listPullRequests(
'https://git.example.test',
'pull-token',
'core-team',
'kanbangit',
new GitRepository(
baseUrl: 'https://git.example.test',
providerRepositoryId: '501',
owner: 'core-team',
name: 'kanbangit',
fullName: 'core-team/kanbangit',
htmlUrl: 'https://git.example.test/core-team/kanbangit',
defaultBranch: 'main',
isPrivate: false,
),
);
$this->assertCount(1, $pullRequests);
@@ -2,6 +2,7 @@
namespace Tests\Feature\Models;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
use App\Models\RepositoryConnection;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -33,4 +34,27 @@ class RepositoryConnectionTest extends TestCase
$this->assertTrue($repositoryConnection->project()->exists());
$this->assertTrue($repositoryConnection->project->is($repositoryConnection->fresh()->project));
}
/**
* Ensure repository connections can be used as Git provider repository data.
*/
public function test_repository_connections_convert_to_git_repository_data(): void
{
$repositoryConnection = RepositoryConnection::factory()->create([
'base_url' => 'https://git.example.test',
'owner_name' => 'core-team',
'provider_repository_id' => '501',
'repository_full_name' => 'core-team/kanbangit',
'repository_name' => 'kanbangit',
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
$repository = $repositoryConnection->toGitRepository();
$this->assertInstanceOf(GitRepository::class, $repository);
$this->assertSame('https://git.example.test', $repository->baseUrl);
$this->assertSame('core-team', $repository->owner);
$this->assertSame('kanbangit', $repository->name);
$this->assertSame('core-team/kanbangit', $repository->fullName);
}
}
@@ -2,6 +2,7 @@
namespace Tests\Feature\Models;
use App\Enums\TicketWorklogEntryType;
use App\Models\TicketWorklogEntry;
use Database\Seeders\BoardColumnSeeder;
use Database\Seeders\ProjectSeeder;
@@ -25,7 +26,7 @@ class TicketWorklogEntryTest extends TestCase
$this->assertNotNull($entry->ticket);
$this->assertNotNull($entry->actor);
$this->assertSame('status_changed', $entry->entry_type);
$this->assertSame(TicketWorklogEntryType::StatusChanged, $entry->entry_type);
}
/**
@@ -13,7 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProjectBacklogControllerTest extends TestCase
class ProjectBacklogTest extends TestCase
{
use RefreshDatabase;
@@ -64,9 +64,9 @@ class ProjectBacklogControllerTest extends TestCase
}
/**
* Ensure creating tickets requires the project create permission.
* Ensure creating tickets requires the project write permission.
*/
public function test_creating_tickets_requires_project_create_permission(): void
public function test_creating_tickets_requires_project_write_permission(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
@@ -81,7 +81,7 @@ class ProjectBacklogControllerTest extends TestCase
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->post(route('projects.backlog.store', $project), [
->post(route('projects.tickets.store', $project), [
'title' => 'New ticket',
'description' => 'Description',
'acceptance_criteria' => 'Criteria',
@@ -93,9 +93,9 @@ class ProjectBacklogControllerTest extends TestCase
}
/**
* Ensure project-local keys increment and backlog accepts duplicate titles.
* Ensure project-local keys increment and ticket creation accepts duplicate titles.
*/
public function test_users_with_create_permission_can_add_duplicate_titled_tickets_to_backlog(): void
public function test_users_with_write_permission_can_add_duplicate_titled_tickets(): void
{
$user = User::factory()->create();
$assignee = User::factory()->create();
@@ -127,14 +127,14 @@ class ProjectBacklogControllerTest extends TestCase
];
$this->actingAs($user)
->post(route('projects.backlog.store', $project), $payload)
->assertRedirect(route('projects.backlog.index', $project))
->assertSessionHas('status', 'AUTH-1 was added to the backlog.');
->post(route('projects.tickets.store', $project), $payload)
->assertRedirect(route('projects.tickets.show', [$project, 1]))
->assertSessionHas('status', 'AUTH-1 was created.');
$this->actingAs($user)
->post(route('projects.backlog.store', $project), $payload)
->assertRedirect(route('projects.backlog.index', $project))
->assertSessionHas('status', 'AUTH-2 was added to the backlog.');
->post(route('projects.tickets.store', $project), $payload)
->assertRedirect(route('projects.tickets.show', [$project, 2]))
->assertSessionHas('status', 'AUTH-2 was created.');
$this->assertDatabaseHas('tickets', [
'project_id' => $project->id,
@@ -164,9 +164,9 @@ class ProjectBacklogControllerTest extends TestCase
}
/**
* Ensure ticket creation only accepts backlog statuses.
* Ensure ticket creation can target board column statuses.
*/
public function test_creating_tickets_rejects_board_column_statuses(): void
public function test_creating_tickets_accepts_board_column_statuses(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
@@ -189,7 +189,7 @@ class ProjectBacklogControllerTest extends TestCase
$this->actingAs($user)
->from(route('projects.backlog.index', $project))
->post(route('projects.backlog.store', $project), [
->post(route('projects.tickets.store', $project), [
'title' => 'New ticket',
'description' => 'Description',
'acceptance_criteria' => 'Criteria',
@@ -197,9 +197,13 @@ class ProjectBacklogControllerTest extends TestCase
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $boardStatus->id,
])
->assertRedirect(route('projects.backlog.index', $project))
->assertSessionHasErrors('ticket_status_id');
->assertRedirect(route('projects.tickets.show', [$project, 1]))
->assertSessionHas('status', "{$project->key}-1 was created.");
$this->assertDatabaseCount('tickets', 0);
$this->assertDatabaseHas('tickets', [
'project_id' => $project->id,
'ticket_status_id' => $boardStatus->id,
'title' => 'New ticket',
]);
}
}
@@ -80,6 +80,7 @@ class ProjectBoardControllerTest extends TestCase
->has('boardColumns.1.tickets', 1)
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
->where('canCreateTickets', false)
->where('canEditTickets', false),
);
}
@@ -312,9 +312,9 @@ class ProjectControllerTest extends TestCase
}
/**
* Ensure non-admin users cannot open general project settings even with project permissions.
* Ensure project admins can open general project settings.
*/
public function test_non_admin_users_cannot_view_general_project_settings(): void
public function test_project_admins_can_view_general_project_settings(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
@@ -325,7 +325,11 @@ class ProjectControllerTest extends TestCase
$this->actingAs($user)
->get(route('projects.settings.general', $project))
->assertForbidden();
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/settings/General')
->where('project.id', $project->id),
);
}
/**
@@ -3,6 +3,7 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
@@ -11,6 +12,7 @@ use App\Models\TicketFieldValue;
use App\Models\TicketLink;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\TicketWorklogEntry;
use App\Models\User;
@@ -274,6 +276,97 @@ class ProjectTicketControllerTest extends TestCase
);
}
/**
* Ensure the create page defaults to a board status when opened from the board.
*/
public function test_users_with_write_permission_can_view_the_ticket_create_page(): void
{
$editor = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$boardColumn = BoardColumn::factory()->for($project)->create([
'name' => 'Todo',
'position' => 1,
]);
$boardStatus = TicketStatus::factory()
->for($project)
->for($boardColumn)
->create([
'name' => 'Todo',
'position' => 1,
]);
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
$this->actingAs($editor)
->get(route('projects.tickets.create', [
'project' => $project,
'source' => 'board',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/TicketCreate')
->where('defaultTicketStatusId', $boardStatus->id)
->where('project.id', $project->id)
->where('source', 'board')
->where('ticketStatusOptions.1.id', $boardStatus->id),
);
}
/**
* Ensure creating tickets from the dedicated endpoint redirects to the ticket detail page.
*/
public function test_users_with_write_permission_can_create_tickets(): void
{
$editor = User::factory()->create();
$assignee = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = $project->ticketPriorities()->firstOrFail();
$backlogStatus = $project->ticketStatuses()
->whereNull('board_column_id')
->firstOrFail();
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
$this->actingAs($editor)
->post(route('projects.tickets.store', $project), [
'acceptance_criteria' => 'The ticket exists.',
'assignee_user_id' => $assignee->id,
'description' => 'Created from the dedicated page.',
'story_points' => 5,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
'ticket_type_id' => $this->nonParentType($project)->id,
'title' => 'Dedicated create page',
])
->assertRedirect(route('projects.tickets.show', [$project, 1]))
->assertSessionHas('status', 'CORE-1 was created.');
$this->assertDatabaseHas('tickets', [
'assignee_user_id' => $assignee->id,
'author_user_id' => $editor->id,
'project_id' => $project->id,
'story_points' => 5,
'ticket_key' => 'CORE-1',
'ticket_status_id' => $backlogStatus->id,
'title' => 'Dedicated create page',
]);
$this->assertDatabaseHas('ticket_worklog_entries', [
'actor_id' => $editor->id,
'entry_type' => 'created',
'summary' => 'Ticket was created in Backlog.',
]);
}
/**
* Ensure updating ticket details requires the project edit permission.
*/