fixed missing features
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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'), '.');
|
||||
}
|
||||
}
|
||||
@@ -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'), '.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -15,3 +15,23 @@
|
||||
- ziel ist einheitlicherere worklog struktur
|
||||
- falls sinnvoll, sonst verwerfen
|
||||
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
|
||||
|
||||
## Umsetzung 2026-04-15
|
||||
|
||||
### Umgesetzt
|
||||
|
||||
- (mehr über middlewares steuern)
|
||||
- mehrere actions/quereys in services/actions auslagern
|
||||
- Git listBranches replace baseUrl, owner, rperoName with GItReprository
|
||||
- Git prs replace baseUrl, owner, rperoName with GItReprository
|
||||
- replace Kanbangit with env App Name
|
||||
- change Project Backlog Controller → Project Controller
|
||||
- remove store ticket from backlog -> create ticket create view.
|
||||
- always show create on board and backlog -> rediurect to ticket create
|
||||
- ggf worklog überarbeiten? → type als worklog enum
|
||||
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
|
||||
|
||||
### Nicht umgesetzt
|
||||
|
||||
- repository_connections check base Url maybe unnessecary? - `base_url` bleibt nötig, um Self-hosted-Git-Instanzen und passende User-Credentials eindeutig zuzuordnen.
|
||||
- handle backlog as a normal column - Backlog bleibt bewusst ein Status ohne `board_column_id`; `/backlog` und `/board` sind nur getrennte Views.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-18
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user